/* * ========================================================================== * Morse-Decoder / Morse-Geber für ESP32 mit SPI-TFT (ILI9341 / ST7735 / ST7789) * ========================================================================== * * Board : AZ-Delivery ESP32 DevKit V4 (ESP32-WROOM-32, ESP32-D0WD-V3, 4 MB Flash) * Display: 1,69" ST7789V2, 240x280 (Pins/Einstellungen aus PlatformIO-Projekt "Test ST7789") * Arduino-Board-Einstellung: "ESP32 Dev Module" (FQBN esp32:esp32:esp32) * * Funktionen * ---------- * DECODER : Morsetaste (Taster gegen GND) an GPIO 27. * - Entprellung in Software * - Punkt/Strich-Erkennung über die Tastdauer, adaptive Tempo- * schätzung (WPM passt sich automatisch an) * - Zeichen- und Wortpausen werden erkannt * - A–Z, 0–9 und gängige Satzzeichen * - Ausgabe auf dem Display (aktuelle Punkte/Striche, Text mit * Zeilenumbruch, WPM) und auf Serial (115200 Baud) * - Mithörton über den Summer, solange die Taste gedrückt ist * * GEBER : Text im seriellen Monitor eingeben (Zeilenende "Neue Zeile") * -> wird als Morse über Summer und LED ausgegeben und angezeigt. * Befehle im seriellen Monitor: * /wpm 18 Geber-Tempo setzen (5..40 WPM) * /ton 650 Tonhöhe in Hz setzen (300..1500) * /clear Display-Text löschen * /help Hilfe anzeigen * * AUDIO-EINGANG (GPIO 34, ADC1_CH6): Morse-Ton z. B. vom Kopfhörerausgang * eines Tablets (Kopplung 1 µF, Bias 2x10 kOhm an 3V3/GND). * - ADC im Continuous-Mode mit 20 kHz (Minimum des ESP32) * - Goertzel-Filter auf die Zielfrequenz (Standard 700 Hz), * Blöcke von ca. 8 ms * - adaptive Schwelle (Rauschboden + Spitzenwert, Hysterese) * - Ergebnis wirkt wie die Morsetaste (ODER-verknüpft) * Befehle: * /freq 700 Zielfrequenz Audio in Hz (300..1500) * /audio Pegel-Debug an/aus (Zeilen beginnen mit "#A") * /src both Quelle: key | audio | both * /stat einmalige Statuszeile (#S ...) * * FUNKTIONSTASTE (GPIO 33, Taster gegen GND): * kurz drücken (< 1 s) : Text löschen * lang drücken (>= 1 s): bisher dekodierten Text als Morse * wieder ausgeben (Echo) * Während der Geber läuft, bricht ein Druck auf die Morsetaste * oder die Funktionstaste die Ausgabe ab. * * Benötigte Bibliotheken (Bibliotheksverwalter): * - Adafruit GFX Library * - Adafruit ILI9341 (für ILI9341) * - Adafruit ST7735 and ST7789 Library (für ST7735 / ST7789) * - Adafruit BusIO (wird automatisch mitinstalliert) */ #include #include // ========================================================================== // 1) DISPLAY-TYP WÄHLEN – genau EINE Zeile aktiv lassen // ========================================================================== //#define DISPLAY_ILI9341 // 2,2"/2,4"/2,8" 240x320 //#define DISPLAY_ST7735 // 1,8" 128x160 #define DISPLAY_ST7789 // Standard: 1,69" ST7789V2 240x280 (abgerundete Ecken) // Nur für ST7735: Variante des Moduls (bei falschen Farben/Rand probieren) // INITR_BLACKTAB, INITR_GREENTAB, INITR_REDTAB, INITR_144GREENTAB #define ST7735_TAB INITR_BLACKTAB // Nur für ST7789: Auflösung des Moduls // Übernommen aus Lutz' PlatformIO-Projekt "Test ST7789" // (.pio/libdeps/.../TFT_eSPI/User_Setup.h: ST7789_2_DRIVER, 240x280, // TFT_RGB, Inversion an, Offset rowstart 20 – macht Adafruit init() selbst) #define ST7789_W 240 #define ST7789_H 280 #define TFT_SPI_HZ 27000000UL // SPI-Takt wie im TFT_eSPI-Projekt (27 MHz) // Bildschirmdrehung: 0 = Hochformat (wie im Projekt), 1 = Querformat #define DISPLAY_ROTATION 0 // Spiegelung (nur ST7789): Ausrichtung entspricht standardmäßig exakt // TFT_eSPI (ST7789_2) aus dem PlatformIO-Projekt. Falls die Schrift trotzdem // gespiegelt oder auf dem Kopf steht, hier umschalten: // MIRROR_X 1 -> links/rechts spiegeln // MIRROR_Y 1 -> oben/unten spiegeln // beide 1 -> 180° drehen #define MIRROR_X 0 #define MIRROR_Y 1 // Standard 1: an Lutz' Modul ist MADCTL 0x80 (nur MY) richtig // Rand in Pixeln (das 1,69"-Display hat stark abgerundete Ecken) #define MARGIN_X 10 #define MARGIN_Y 8 // (headH wird bei Textgröße 2 um MARGIN_Y vergrößert) // ========================================================================== // 2) PINBELEGUNG (AZ-Delivery ESP32 DevKit V4 / ESP32-WROOM-32) // Display-Pins 1:1 aus dem PlatformIO-Projekt "Test ST7789" übernommen // (TFT_eSPI User_Setup.h). Alle Pins sind am DevKit V4 herausgeführt. // Hinweis: GPIO 2 (DC) ist ein Strapping-Pin; im bestehenden Aufbau // funktioniert das (Flashen/Booten getestet), daher beibehalten. // ========================================================================== #define PIN_TFT_SCK 14 // SPI SCLK -> Display SCL #define PIN_TFT_MOSI 13 // SPI MOSI -> Display SDA #define PIN_TFT_CS 26 // Chip Select -> Display CS #define PIN_TFT_DC 2 // Data/Command -> Display DC #define PIN_TFT_RST 4 // Reset -> Display RST #define PIN_TFT_BL -1 // Hintergrundbeleuchtung: im Projekt nicht // definiert -> BL an 3V3 (oder offen, falls // das Modul einen Pull-up hat). GPIO hier // eintragen, falls BL an einem Pin hängt. // MISO wird nicht benötigt (im TFT_eSPI-Projekt nur formal GPIO 19). #define PIN_KEY 27 // Morsetaste, Taster gegen GND (INPUT_PULLUP) #define PIN_FUNC 33 // Funktionstaste, Taster gegen GND (INPUT_PULLUP) // (verlegt von 26, weil 26 jetzt Display-CS ist) #define PIN_AUDIO 34 // Audio-Eingang (nur Eingang, ADC1 Kanal 6) // 1 µF Koppelkondensator, Bias 2x10 kOhm #define PIN_BUZZER 25 // Summer (passiv empfohlen), andere Seite GND #define PIN_LED -1 // Geber-LED: GPIO 2 ist DC -> LED deaktiviert. // Optional externe LED + 330 Ohm an GPIO 22 // und hier 22 eintragen. // Summer-Typ: passiver Summer/Lautsprecher -> Ton per PWM (tone()). // Bei aktivem Summer (piept selbst bei Gleichspannung) auf 0 setzen. #define BUZZER_PASSIVE 1 // ========================================================================== // 3) EINSTELLUNGEN // ========================================================================== #define SERIAL_BAUD 115200 #define DEBOUNCE_MS 10 // Entprellzeit Tasten #define START_WPM 15 // Start-Schätzung Decoder-Tempo #define MIN_WPM 5 #define MAX_WPM 40 #define CHAR_GAP_UNITS 2.5f // Pause > 2,5 Einheiten -> Zeichenende #define WORD_GAP_UNITS 5.0f // Startwert Wortpause (danach adaptiv, s. wordGapUnits()) #define LONG_PRESS_MS 1000 // Funktionstaste lang // Audio-Decoder #define AUDIO_FS_HZ 20000 // ADC-Abtastrate (ESP32: min. 20 kHz) #define AUDIO_BLOCK_MS 8 // Goertzel-Blocklänge in ms #define AUDIO_FREQ_HZ 700 // Zielfrequenz (Standard) #define AUDIO_MIN_MAG 12.0f // absolute Mindestschwelle (ADC-Counts Amplitude) #define AUDIO_SNR_ON 4.0f // Ton erst ab 4x Rauschboden #define AUDIO_HYST 0.6f // Aus-Schwelle = 60 % der Ein-Schwelle #define AUDIO_HP_HZ 200.0f // Hochpass vor dem Goertzel (gegen Knacken/Brumm) #define AUDIO_TONAL_ON 0.40f // Anteil der Zielfrequenz an der Blockenergie (Ein) #define AUDIO_TONAL_HOLD 0.20f // ... zum Halten (Hysterese) #define AUDIO_SIDETONE 0 // 1 = Summer piept auch bei Audio-Eingang // Selbsttest ohne Tablet: 1 = ADC liest GPIO 32 statt 34, und GPIO 32 erzeugt // selbst Morse ("PARIS SOS 0123456789") als 700-Hz-Rechteck. Prüft ADC, // Goertzel, Schwelle und Decoder ohne externe Verdrahtung. Normal: 0. #ifndef AUDIO_SELFTEST #define AUDIO_SELFTEST 0 #endif #define SELFTEST_PIN 32 // Selbsttest-Tempo wechselt pro Durchlauf: 12, 18, 25 WPM // ========================================================================== // Display-Objekt anlegen // ========================================================================== #if defined(DISPLAY_ILI9341) #include Adafruit_ILI9341 tft(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST); #define TXT_SIZE 2 #elif defined(DISPLAY_ST7735) #include Adafruit_ST7735 tft(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST); #define TXT_SIZE 1 #elif defined(DISPLAY_ST7789) #include Adafruit_ST7789 tft(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST); #define TXT_SIZE 2 #else #error "Bitte genau einen Display-Typ (DISPLAY_ILI9341/ST7735/ST7789) wählen" #endif // Farben (RGB565) #define C_BG 0x0000 // schwarz #define C_HEAD 0x001F // blau #define C_TEXT 0xFFFF // weiß – empfangener (dekodierter) Text #define C_SENT 0xFFE0 // gelb – gesendeter Text #define C_SYM 0x07E0 // grün – Punkte/Striche #define C_INFO 0xC618 // hellgrau #define C_BAR 0x07E0 // grün – Bargraph-Balken #define C_BARTGT 0xFFE0 // gelb – Balken der Zielfrequenz (/freq) #define C_HOLD 0xF800 // rot – Peak-Hold-Marke #define C_GRID 0x4208 // dunkelgrau – Raster/Mittenmarke // ========================================================================== // Morse-Tabelle // ========================================================================== struct MorseCode { char c; const char *code; }; // Entprellter Taster (hier oben definiert, damit die von der Arduino-IDE // automatisch erzeugten Funktionsprototypen den Typ schon kennen) struct Button { uint8_t pin; bool stable; // entprellter Zustand (true = gedrückt) bool lastRaw; uint32_t lastChange; // Zeitpunkt letzter Rohwert-Änderung uint32_t since; // Zeitpunkt letzter stabiler Änderung }; const MorseCode MORSE[] = { {'A', ".-"}, {'B', "-..."}, {'C', "-.-."}, {'D', "-.."}, {'E', "."}, {'F', "..-."}, {'G', "--."}, {'H', "...."}, {'I', ".."}, {'J', ".---"}, {'K', "-.-"}, {'L', ".-.."}, {'M', "--"}, {'N', "-."}, {'O', "---"}, {'P', ".--."}, {'Q', "--.-"}, {'R', ".-."}, {'S', "..."}, {'T', "-"}, {'U', "..-"}, {'V', "...-"}, {'W', ".--"}, {'X', "-..-"}, {'Y', "-.--"}, {'Z', "--.."}, {'0', "-----"}, {'1', ".----"}, {'2', "..---"}, {'3', "...--"}, {'4', "....-"}, {'5', "....."}, {'6', "-...."}, {'7', "--..."}, {'8', "---.."}, {'9', "----."}, {'.', ".-.-.-"}, {',', "--..--"}, {'?', "..--.."}, {'\'', ".----."}, {'!', "-.-.--"}, {'/', "-..-."}, {'(', "-.--."}, {')', "-.--.-"}, {'&', ".-..."}, {':', "---..."}, {';', "-.-.-."}, {'=', "-...-"}, {'+', ".-.-."}, {'-', "-....-"}, {'_', "..--.-"}, {'"', ".-..-."}, {'$', "...-..-"},{'@', ".--.-."}, }; const int MORSE_COUNT = sizeof(MORSE) / sizeof(MORSE[0]); // Morsefolge (".-") -> Zeichen, '*' wenn unbekannt char decodeMorse(const char *code) { for (int i = 0; i < MORSE_COUNT; i++) if (strcmp(MORSE[i].code, code) == 0) return MORSE[i].c; return '*'; } // Zeichen -> Morsefolge, nullptr wenn unbekannt const char *encodeChar(char c) { c = toupper((unsigned char)c); // Umlaute werden vorher in sendText() umgesetzt for (int i = 0; i < MORSE_COUNT; i++) if (MORSE[i].c == c) return MORSE[i].code; return nullptr; } // ========================================================================== // Ton / LED // ========================================================================== int toneHz = 700; // Tonhöhe, per /ton änderbar void signalOn() { #if BUZZER_PASSIVE tone(PIN_BUZZER, toneHz); #else digitalWrite(PIN_BUZZER, HIGH); #endif } void signalOff() { #if BUZZER_PASSIVE noTone(PIN_BUZZER); #else digitalWrite(PIN_BUZZER, LOW); #endif } void ledSet(bool on) { #if PIN_LED >= 0 digitalWrite(PIN_LED, on ? HIGH : LOW); #endif } // ========================================================================== // Display-Layout // ========================================================================== // +--------------------------------------+ // | MORSE ESP32 RX 15 WPM TX 18 | Kopfzeile // | · − · | aktuelle Punkte/Striche // |--------------------------------------| // | dekodierter Text mit Zeilenumbruch | Textbereich // | ... | // +--------------------------------------+ int16_t W, H; // Displaygröße nach Rotation int16_t charW, charH; // Zeichengröße in Pixeln int16_t headH, symY, symH, textY; int cols, rows; // Zeichen pro Zeile / Zeilen im Textbereich int16_t textBot; // unterer Rand des Textbereichs bool barOn = true; // Bargraph (Abstimmanzeige) an/aus, Befehl /bar on|off #define LVL_AREA_H 13 // Pegelbalken (10 px + Abstand) oben im Bargraph-Bereich #define BAR_AREA_H (78 + LVL_AREA_H) // Höhe Bargraph-Bereich unten (Pegel + Text + Balken + Skala) int16_t barY0; // Oberkante Bargraph-Bereich const int MAX_TEXT = 400; // Obergrenze Textpuffer char textBuf[MAX_TEXT]; uint16_t textCol[MAX_TEXT]; int textLen = 0; float unitMs = 1200.0f / START_WPM; // Decoder: geschätzte Punktlänge in ms int txWpm = 18; // Geber-Tempo void drawHeader() { tft.fillRect(0, 0, W, headH, C_HEAD); tft.setTextSize(1); tft.setTextColor(C_TEXT, C_HEAD); tft.setCursor(MARGIN_X, MARGIN_Y + (headH - MARGIN_Y - 8) / 2); tft.print("MORSE ESP32"); char buf[32]; snprintf(buf, sizeof(buf), "RX %2d WPM TX %2d", (int)(1200.0f / unitMs + 0.5f), txWpm); tft.setCursor(W - (int)strlen(buf) * 6 - MARGIN_X, MARGIN_Y + (headH - MARGIN_Y - 8) / 2); tft.print(buf); } // Aktuelle Punkte/Striche grafisch zeichnen (dot = Kreis, dash = Balken) void drawSymbols(const char *sym) { tft.fillRect(0, symY, W, symH, C_BG); int u = (TXT_SIZE >= 2) ? 4 : 2; // Grundgröße int x = MARGIN_X; int cy = symY + symH / 2; for (const char *p = sym; *p && x < W - 8 * u; p++) { if (*p == '.') { tft.fillCircle(x + u, cy, u, C_SYM); x += 2 * u + 3 * u; } else { tft.fillRect(x, cy - u, 6 * u, 2 * u, C_SYM); x += 6 * u + 3 * u; } } } // Zeichen an Position i im Textbereich zeichnen void drawTextChar(int i) { int line = i / cols, col = i % cols; tft.setTextSize(TXT_SIZE); tft.setTextColor(textCol[i], C_BG); tft.setCursor(MARGIN_X + col * charW, textY + 2 + line * charH); tft.print(textBuf[i]); } void redrawText() { tft.fillRect(0, textY + 1, W, textBot - textY - 1, C_BG); for (int i = 0; i < textLen; i++) drawTextChar(i); } void clearText() { textLen = 0; redrawText(); Serial.println(F("\n[Text gelöscht]")); } // Zeichen an Text anhängen; bei vollem Bildschirm eine Zeile nach oben schieben void appendChar(char c, uint16_t color) { if (c == ' ' && (textLen == 0 || textBuf[textLen - 1] == ' ')) return; // keine Doppel-Leerzeichen int capacity = cols * rows; if (textLen >= capacity) { memmove(textBuf, textBuf + cols, textLen - cols); memmove(textCol, textCol + cols, (textLen - cols) * sizeof(uint16_t)); textLen -= cols; textBuf[textLen] = c; textCol[textLen] = color; textLen++; redrawText(); } else { textBuf[textLen] = c; textCol[textLen] = color; textLen++; drawTextChar(textLen - 1); } } // -------------------------------------------------------------------------- // Ausrichtung (MADCTL) passend zu TFT_eSPI setzen – nur ST7789 // Adafruit_ST7789::setRotation(0) setzt MADCTL = MX|MY (0xC0), TFT_eSPI // (ST7789_2) dagegen 0x00. Darum wird MADCTL nach setRotation() hier mit den // TFT_eSPI-Werten überschrieben. Die Offsets für 240x280 (je 20 Zeilen oben // und unten, 0 Spalten) sind symmetrisch – Spiegeln verschiebt daher nichts, // Adafruits setAddrWindow()-Offsets bleiben gültig. // -------------------------------------------------------------------------- #define MADCTL_MY 0x80 #define MADCTL_MX 0x40 #define MADCTL_MV 0x20 #define MADCTL_CMD 0x36 void applyOrientation() { tft.setRotation(DISPLAY_ROTATION); // Breite/Höhe + Offsets (Adafruit) #if defined(DISPLAY_ST7789) // MADCTL-Werte wie TFT_eSPI ST7789_2_Rotation.h, Farbreihenfolge RGB (Bit3 = 0) static const uint8_t base[4] = { 0x00, // 0: Hochformat MADCTL_MX | MADCTL_MV, // 1: Querformat MADCTL_MX | MADCTL_MY, // 2: Hochformat 180° MADCTL_MV | MADCTL_MY // 3: Querformat 180° }; uint8_t m = base[DISPLAY_ROTATION & 3]; if (MIRROR_X) m ^= MADCTL_MX; if (MIRROR_Y) m ^= MADCTL_MY; tft.sendCommand(MADCTL_CMD, &m, 1); Serial.printf("Display: ST7789 %dx%d, Rotation %d, MADCTL 0x%02X\n", (int)tft.width(), (int)tft.height(), DISPLAY_ROTATION, m); #endif } void setupDisplay() { #if PIN_TFT_BL >= 0 pinMode(PIN_TFT_BL, OUTPUT); digitalWrite(PIN_TFT_BL, HIGH); // Beleuchtung an #endif SPI.begin(PIN_TFT_SCK, -1, PIN_TFT_MOSI, PIN_TFT_CS); #if defined(DISPLAY_ILI9341) tft.begin(); #elif defined(DISPLAY_ST7735) tft.initR(ST7735_TAB); #elif defined(DISPLAY_ST7789) tft.init(ST7789_W, ST7789_H, SPI_MODE0); #endif tft.setSPISpeed(TFT_SPI_HZ); applyOrientation(); W = tft.width(); H = tft.height(); charW = 6 * TXT_SIZE; charH = 8 * TXT_SIZE + 2; headH = ((TXT_SIZE >= 2) ? 16 : 12) + MARGIN_Y; symY = headH + 2; symH = (TXT_SIZE >= 2) ? 20 : 12; textY = symY + symH + 2; layoutScreen(); } void barDrawStatic(); // Bargraph-Rahmen (weiter unten definiert) // Textbereich (und ggf. Bargraph-Bereich) neu einteilen und alles neu zeichnen void layoutScreen() { barY0 = H - MARGIN_Y - BAR_AREA_H; textBot = barOn ? barY0 - 2 : H - MARGIN_Y; cols = (W - 2 * MARGIN_X) / charW; rows = (textBot - textY - 2) / charH; if (rows < 1) rows = 1; if (cols * rows > MAX_TEXT) rows = MAX_TEXT / cols; // Text kürzen, falls er nicht mehr passt (neueste Zeilen behalten) while (textLen > cols * rows) { memmove(textBuf, textBuf + cols, textLen - cols); memmove(textCol, textCol + cols, (textLen - cols) * sizeof(uint16_t)); textLen -= cols; } tft.fillScreen(C_BG); drawHeader(); drawSymbols(""); tft.drawFastHLine(0, textY, W, C_INFO); redrawText(); if (barOn) barDrawStatic(); } // ========================================================================== // Entprellter Taster // ========================================================================== Button keyBtn = {PIN_KEY, false, false, 0, 0}; // kombinierter Zustand (Taste ODER Ton) Button physBtn = {PIN_KEY, false, false, 0, 0}; // entprellte Morsetaste allein Button funcBtn = {PIN_FUNC, false, false, 0, 0}; // Liefert true, wenn sich der entprellte Zustand geändert hat. // raw = true bedeutet "gedrückt" (bzw. Ton erkannt). bool updateButtonRaw(Button &b, bool raw, uint32_t now) { if (raw != b.lastRaw) { b.lastRaw = raw; b.lastChange = now; } if (raw != b.stable && (now - b.lastChange) >= DEBOUNCE_MS) { b.stable = raw; b.since = now; return true; } return false; } bool updateButton(Button &b, uint32_t now) { return updateButtonRaw(b, digitalRead(b.pin) == LOW, now); // gegen GND } // ========================================================================== // AUDIO-EINGANG: ADC continuous + Goertzel // ========================================================================== #include "esp_adc/adc_continuous.h" #include adc_continuous_handle_t adcHandle = nullptr; #if AUDIO_SELFTEST const uint8_t pat_channel = ADC_CHANNEL_4; #else const uint8_t pat_channel = ADC_CHANNEL_6; #endif bool audioOk = false; int audioFreq = AUDIO_FREQ_HZ; bool audioDebug = false; volatile bool audioOn = false; // Ton erkannt (nach Schwelle/Hysterese) // Ton-Flanken mit Zeitstempel aus dem Abtastzähler: Der Decoder bekommt die // echten Zeitpunkte, auch wenn loop() kurz durch Displayausgaben (Scrollen, // Bargraph) aufgehalten wurde – die ADC-Daten liegen solange im DMA-Puffer. struct AudioEdge { uint32_t t; bool on; }; #define AEDGE_N 32 AudioEdge aEdge[AEDGE_N]; volatile int aEdgeHead = 0, aEdgeTail = 0; uint32_t aProcIdx = 0; // Abtastwerte im aktuellen audioLoop()-Aufruf uint32_t aPendIdx[AEDGE_N]; bool aPendOn[AEDGE_N]; int aPendN = 0; bool audioState = false; // Ton-Zustand laut Flanken-Warteschlange #define AUDIO_LAT_MS 20 // Verzögerung ADC-Frame + Goertzel-Block: Pausen erst so spät bewerten // Quelle für den Decoder: Bit0 = Taste, Bit1 = Audio #define SRC_KEY 1 #define SRC_AUDIO 2 uint8_t keySource = SRC_KEY | SRC_AUDIO; // Goertzel-Zustand float gCoeff = 0, gS1 = 0, gS2 = 0; int gN = 160, gCount = 0; float dcEst = 2048, blockSum = 0; float audioMag = 0, noiseFloor = 5, peakMag = 0, thrOn = AUDIO_MIN_MAG, thrOff = AUDIO_MIN_MAG * AUDIO_HYST; float fsMeasured = AUDIO_FS_HZ; float hpA = 0.93f, hpY = 0, hpX = 0; // Hochpass-Zustand float blockEnergy = 0, tonality = 0; // Energie im Block, Tonanteil uint32_t sampleCount = 0, fsT0 = 0, lastDbg = 0, fsLastChk = 0; int rawMin = 4095, rawMax = 0; // Spitzenwerte für Debug uint32_t zcCount = 0, zcSamples = 0; // Nulldurchgänge (Frequenzkontrolle) bool zcPos = false; uint32_t loopMaxUs = 0; // längste loop()-Laufzeit (ohne serialLoop) seit letzter Statuszeile uint32_t barMaxUs = 0; // längste Bargraph-Zeichenzeit uint32_t onEdges = 0; // Anzahl Ton-Ein-Flanken seit letzter Ausgabe // /dump: 4096 Rohwerte am Stück aufzeichnen und ausgeben (Signalanalyse) #define DUMP_LEN 4096 int16_t dumpBuf[DUMP_LEN]; int dumpPos = -1; // -1 = aus // ========================================================================== // BARGRAPH / ABSTIMMANZEIGE // 25 Goertzel-Filter 400..1000 Hz (25-Hz-Raster) auf denselben ADC-Daten // (nach dem Hochpass), Hann-Fenster über 1024 Abtastwerte (~51 ms bei 20 kHz) // -> ca. 20 Spektren/s. Spitzenfrequenz per Parabel-Interpolation zwischen // den Bins (Auflösung wenige Hz). Rechenlast ~3 % eines Kerns; gezeichnet // wird höchstens 15x/s und nur die geänderten Balkenteile (kein Flimmern, // kurze SPI-Zeit -> Decoder-Timing bleibt unberührt, siehe /stat loopMax). // ========================================================================== #define BAR_F0 400 #define BAR_STEP 25 #define BAR_N 25 // 400..1000 Hz #define BAR_WIN 1024 // Fensterlänge in Abtastwerten #define BAR_DB_MIN 0.0f // 0 dB = 1 ADC-Count Amplitude #define BAR_DB_MAX 60.0f // 60 dB = 1000 Counts (Vollaussteuerung ~1200) #define BAR_HOLD_DECAY 0.6f // Peak-Hold fällt um 0,6 dB pro Spektrum (~12 dB/s) #define BAR_FPS_MS 66 // max. ~15 Bilder/s float barCoef[BAR_N], barS1[BAR_N], barS2[BAR_N]; float barHann[BAR_WIN]; int barPos = 0; float barDb[BAR_N], barHold[BAR_N]; // geglättet / Peak-Hold (dB) int16_t barDrawnH[BAR_N], barDrawnHold[BAR_N]; bool barNew = false; float barPeakHz = 0, barPeakDb = 0, barNoiseDb = 0; bool barPeakValid = false; uint32_t barLastDraw = 0; int barDrawnTarget = -1; char barDrawnTxt[40] = ""; uint32_t barLastTxt = 0; GFXcanvas16 barCanvas(220, 8); // Puffer für die Peak-Textzeile // ========================================================================== // PEGELBALKEN (Aussteuerung ADC-Eingang), waagerecht, ohne Schrift // Messgröße: max |Rohwert - dc| je Fenster (1024 Werte ~ 63 ms), Skala // abschnittsweise (siehe lvlAmpToPx). Zonen: gelb = zu leise (< 100 Counts), // grün = gut (Spitzen innerhalb 300..3800), rot = darüber / Clip. // Clip (Rohwert <= 8 oder >= 4087): Rahmen 1 s rot. Peak-Hold-Strich weiß, // hält 0,5 s und fällt dann mit ~60 px/s. // ========================================================================== #define LVL_WIN 1024 #define LVL_QUIET 100.0f // darunter "zu leise" #define LVL_GOOD_LO 300 // Rohwert-Grenzen "gut" #define LVL_GOOD_HI 3800 #define LVL_CLIP_LO 8 #define LVL_CLIP_HI 4087 #define LVL_CLIP_MS 1000 #define C_LVL_Y 0xFFE0 // gelb #define C_LVL_G 0x07E0 // grün #define C_LVL_R 0xF800 // rot #define C_LVL_YD 0x3180 // dunkelgelb (Hintergrund-Zone) #define C_LVL_GD 0x0180 // dunkelgrün #define C_LVL_RD 0x3000 // dunkelrot #define C_LVL_FRAME C_GRID int lvlMin = 4095, lvlMax = 0, lvlCnt = 0; volatile float lvlAmp = 0; // letzte Fenster-Amplitude (Counts) volatile bool lvlNew = false; uint32_t lvlClipUntil = 0; // rote Markierung bis bool lvlClipAny = false; // Clip seit letzter Statuszeile float lvlHoldPx = 0; uint32_t lvlHoldT = 0; int16_t lvlDrawnFill = 0, lvlDrawnHold = -1; int8_t lvlDrawnClip = -1; int16_t lvlXq = 0, lvlXg = 0; // Zonengrenzen in Pixeln (relativ zum Innenbereich) void lvlWindowDone() { float dc = dcEst; float a = max(dc - lvlMin, lvlMax - dc); lvlAmp = a < 0 ? 0 : a; if (lvlMin <= LVL_CLIP_LO || lvlMax >= LVL_CLIP_HI) { lvlClipUntil = millis() + LVL_CLIP_MS; lvlClipAny = true; } lvlMin = 4095; lvlMax = 0; lvlCnt = 0; lvlNew = true; } // Geometrie: Rahmen über volle Breite (mit Rand), Innenbereich 8 px hoch int16_t lvlX() { return MARGIN_X; } int16_t lvlY() { return barY0 + 3; } int16_t lvlW() { return W - 2 * MARGIN_X; } // inkl. Rahmen int16_t lvlIW() { return lvlW() - 2; } // Innenbreite float lvlGoodAmp = 1650; // Amplitude, ab der die Spitzen 300/3800 überschreiten // Abschnittsweise Skala, damit jede Zone gut sichtbar ist: // 10..100 Counts (log) -> 0..35 % gelb // 100..gut (log) -> 35..85 % grün // gut..2048 (linear) -> 85..100 % rot int16_t lvlAmpToPx(float a) { float v; float q = LVL_QUIET, g = lvlGoodAmp; if (a <= q) v = a <= 10 ? 0 : 0.35f * log10f(a / 10.0f) / log10f(q / 10.0f); else if (a <= g) v = 0.35f + 0.50f * log10f(a / q) / log10f(g / q); else v = 0.85f + 0.15f * (a - g) / (2048.0f - g); return (int16_t)constrain(v * lvlIW(), 0.0f, (float)lvlIW()); } // Farbe eines Pixels im Innenbereich je Zone (hell = Balken, dunkel = Hintergrund) void lvlFill(int16_t a, int16_t b, bool bright) { // Pixel [a, b) zeichnen int16_t x0 = lvlX() + 1, y = lvlY() + 1; const int16_t lim[3] = {lvlXq, lvlXg, lvlIW()}; const uint16_t cb[3] = {C_LVL_Y, C_LVL_G, C_LVL_R}, cd[3] = {C_LVL_YD, C_LVL_GD, C_LVL_RD}; int16_t start = 0; for (int z = 0; z < 3; z++) { int16_t lo = max(a, start), hi = min(b, lim[z]); if (hi > lo) tft.fillRect(x0 + lo, y, hi - lo, 8, bright ? cb[z] : cd[z]); start = lim[z]; } } void lvlDrawStatic() { // grün ab "zu leise", rot ab der Grenze, an der die Spitzen 300/3800 überschreiten float dc = dcEst > 500 && dcEst < 3600 ? dcEst : 2048; lvlGoodAmp = min(dc - LVL_GOOD_LO, LVL_GOOD_HI - dc); lvlXq = lvlAmpToPx(LVL_QUIET); lvlXg = lvlAmpToPx(lvlGoodAmp); tft.drawRect(lvlX(), lvlY(), lvlW(), 10, C_LVL_FRAME); lvlFill(0, lvlIW(), false); lvlDrawnFill = 0; lvlDrawnHold = -1; lvlDrawnClip = -1; } void lvlDraw(uint32_t now) { if (!lvlNew) return; lvlNew = false; int16_t px = lvlAmpToPx(lvlAmp); // Peak-Hold: 0,5 s halten, dann ~60 px/s abfallen if (px >= lvlHoldPx) { lvlHoldPx = px; lvlHoldT = now; } else if (now - lvlHoldT > 500) lvlHoldPx = max((float)px, lvlHoldPx - 60.0f * 0.064f); int16_t old = lvlDrawnFill; if (px > old) lvlFill(old, px, true); else if (px < old) lvlFill(px, old, false); lvlDrawnFill = px; int16_t h = (int16_t)lvlHoldPx; if (h >= lvlIW() - 1) h = lvlIW() - 2; if (h != lvlDrawnHold || px != old) { if (lvlDrawnHold >= 0 && lvlDrawnHold != h) lvlFill(lvlDrawnHold, lvlDrawnHold + 2, lvlDrawnHold + 2 <= px); if (h > px + 1) tft.fillRect(lvlX() + 1 + h, lvlY() + 1, 2, 8, C_TEXT); lvlDrawnHold = (h > px + 1) ? h : -1; } int8_t clip = (int32_t)(lvlClipUntil - now) > 0 ? 1 : 0; if (clip != lvlDrawnClip) { tft.drawRect(lvlX(), lvlY(), lvlW(), 10, clip ? C_LVL_R : C_LVL_FRAME); if (clip) tft.drawRect(lvlX() - 1, lvlY() - 1, lvlW() + 2, 12, C_LVL_R); else tft.drawRect(lvlX() - 1, lvlY() - 1, lvlW() + 2, 12, C_BG); lvlDrawnClip = clip; } } void barSetup(float fs) { for (int k = 0; k < BAR_N; k++) { float f = BAR_F0 + k * BAR_STEP; barCoef[k] = 2.0f * cosf(2.0f * PI * f / fs); barS1[k] = barS2[k] = 0; } for (int n = 0; n < BAR_WIN; n++) barHann[n] = 0.5f - 0.5f * cosf(2.0f * PI * n / (BAR_WIN - 1)); barPos = 0; } // Ein Spektrum ist fertig: dB, Glättung, Peak-Hold, Spitzenfrequenz void barBlockDone() { float raw[BAR_N]; for (int k = 0; k < BAR_N; k++) { float p = barS1[k] * barS1[k] + barS2[k] * barS2[k] - barCoef[k] * barS1[k] * barS2[k]; if (p < 0) p = 0; float amp = 2.0f * sqrtf(p) / (BAR_WIN * 0.5f); // Hann-Verstärkung 0,5 -> Amplitude in Counts raw[k] = 20.0f * log10f(amp + 0.1f); barS1[k] = barS2[k] = 0; barDb[k] = 0.5f * barDb[k] + 0.5f * raw[k]; // Glättung barHold[k] -= BAR_HOLD_DECAY; if (barDb[k] > barHold[k]) barHold[k] = barDb[k]; } // Spitze im ungeglätteten Spektrum + Parabel-Interpolation int km = 0; for (int k = 1; k < BAR_N; k++) if (raw[k] > raw[km]) km = k; float d = 0; if (km > 0 && km < BAR_N - 1) { float a = raw[km - 1], b = raw[km], c = raw[km + 1]; float den = a - 2 * b + c; if (den < -1e-3f) d = 0.5f * (a - c) / den; d = constrain(d, -0.5f, 0.5f); } // Rauschboden = Median der Bins float tmp[BAR_N]; memcpy(tmp, raw, sizeof(tmp)); for (int i = 1; i < BAR_N; i++) { float v = tmp[i]; int j = i - 1; while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j--; } tmp[j + 1] = v; } barNoiseDb = tmp[BAR_N / 2]; barPeakDb = raw[km]; barPeakValid = (barPeakDb > barNoiseDb + 10.0f) && (barPeakDb > 15.0f); float hz = BAR_F0 + (km + d) * BAR_STEP; barPeakHz = barPeakValid ? (barPeakHz > 0 && fabsf(hz - barPeakHz) < 30 ? 0.6f * hz + 0.4f * barPeakHz : hz) : 0; barNew = true; } inline void barSample(float x) { float xw = x * barHann[barPos]; for (int k = 0; k < BAR_N; k++) { float s0 = xw + barCoef[k] * barS1[k] - barS2[k]; barS2[k] = barS1[k]; barS1[k] = s0; } if (++barPos >= BAR_WIN) { barPos = 0; barBlockDone(); } } // Geometrie: Textzeile (Peak), Balken (50 px), Skala #define BAR_TXT_H 12 #define BAR_PLOT_H 50 #define BAR_PITCH 8 #define BAR_W 6 int16_t barX0() { return (W - BAR_N * BAR_PITCH) / 2 + 1; } int16_t barPlotTop() { return barY0 + LVL_AREA_H + BAR_TXT_H + 2; } int16_t barPlotBot() { return barPlotTop() + BAR_PLOT_H; } // erste Zeile unter den Balken int barTargetBin() { return constrain((audioFreq - BAR_F0 + BAR_STEP / 2) / BAR_STEP, -1, BAR_N); } int16_t barDbToH(float db) { float v = (db - BAR_DB_MIN) / (BAR_DB_MAX - BAR_DB_MIN); return (int16_t)constrain(v * BAR_PLOT_H, 0.0f, (float)BAR_PLOT_H); } void barDrawStatic() { tft.fillRect(0, barY0, W, BAR_AREA_H, C_BG); tft.drawFastHLine(0, barY0, W, C_INFO); int16_t yb = barPlotBot(), x0 = barX0(); tft.drawFastHLine(x0 - 1, yb, BAR_N * BAR_PITCH, C_GRID); tft.setTextSize(1); tft.setTextColor(C_INFO, C_BG); const int lab[3] = {0, 12, 24}; // 400 / 700 / 1000 Hz for (int i = 0; i < 3; i++) { int k = lab[i], xc = x0 + k * BAR_PITCH + BAR_W / 2; tft.drawFastVLine(xc, yb + 1, (k == 12) ? 5 : 3, (k == 12) ? C_TEXT : C_INFO); // Mittenmarke 700 Hz char b[6]; snprintf(b, sizeof(b), "%d", BAR_F0 + k * BAR_STEP); int tw = strlen(b) * 6, tx = constrain(xc - tw / 2, 0, W - tw); tft.setCursor(tx, yb + 7); tft.print(b); } for (int k = 0; k < BAR_N; k++) { barDrawnH[k] = 0; barDrawnHold[k] = -1; } barDrawnTarget = -1; barDrawnTxt[0] = 0; lvlDrawStatic(); } void barDraw(uint32_t now) { if (!barOn || !barNew || now - barLastDraw < BAR_FPS_MS) return; barNew = false; barLastDraw = now; int16_t yb = barPlotBot(), x0 = barX0(); int tgt = barTargetBin(); bool tgtChanged = (tgt != barDrawnTarget); for (int k = 0; k < BAR_N; k++) { int16_t x = x0 + k * BAR_PITCH; uint16_t col = (k == tgt) ? C_BARTGT : C_BAR; int16_t h = barDbToH(barDb[k]), oh = barDrawnH[k]; if (tgtChanged && (k == tgt || k == barDrawnTarget)) oh = 0, tft.fillRect(x, yb - BAR_PLOT_H, BAR_W, BAR_PLOT_H, C_BG), barDrawnHold[k] = -1; if (h > oh) tft.fillRect(x, yb - h, BAR_W, h - oh, col); else if (h < oh) tft.fillRect(x, yb - oh, BAR_W, oh - h, C_BG); barDrawnH[k] = h; // Peak-Hold-Marke (2 px), nur zeichnen, wenn sie über dem Balken liegt int16_t hh = barDbToH(barHold[k]); if (hh < 2) hh = -1; if (hh != barDrawnHold[k]) { int16_t oy = barDrawnHold[k]; if (oy >= 0 && oy > h) tft.fillRect(x, yb - oy, BAR_W, 2, C_BG); // alte Marke löschen if (hh > h + 1) tft.fillRect(x, yb - hh, BAR_W, 2, C_HOLD); barDrawnHold[k] = (hh > h + 1) ? hh : -1; } } barDrawnTarget = tgt; // Textzeile: Spitzenfrequenz, Ablage zur Zielfrequenz, Pfeil-Hinweis char txt[40]; if (barPeakValid) { int pk = (int)(barPeakHz + 0.5f), dv = pk - audioFreq; const char *hint = (dv > 15) ? " tiefer v" : (dv < -15) ? " hoeher ^" : " OK"; snprintf(txt, sizeof(txt), "Peak %4d Hz %+4d%s", pk, dv, hint); } else { snprintf(txt, sizeof(txt), "Peak --- Ziel %d Hz", audioFreq); } // Text nur bei Änderung und höchstens 4x/s; in einen RAM-Puffer schreiben // und als ein Block übertragen (~1 ms statt Pixel für Pixel) if (strcmp(txt, barDrawnTxt) && now - barLastTxt >= 250) { barLastTxt = now; bool ok = barPeakValid && abs((int)(barPeakHz + 0.5f) - audioFreq) <= 15; barCanvas.fillScreen(C_BG); barCanvas.setTextSize(1); barCanvas.setTextColor(ok ? C_SYM : (barPeakValid ? C_BARTGT : C_INFO)); barCanvas.setCursor(0, 0); barCanvas.print(txt); tft.drawRGBBitmap(MARGIN_X, barY0 + LVL_AREA_H + 3, barCanvas.getBuffer(), barCanvas.width(), barCanvas.height()); strcpy(barDrawnTxt, txt); } } void audioSetup() { gN = (int)(fsMeasured * AUDIO_BLOCK_MS / 1000.0f + 0.5f); float k = gN * (float)audioFreq / fsMeasured; // nicht ganzzahliger Bin ist ok gCoeff = 2.0f * cosf(2.0f * PI * k / gN); gS1 = gS2 = 0; gCount = 0; blockSum = 0; blockEnergy = 0; float rc = 1.0f / (2.0f * PI * AUDIO_HP_HZ), dt = 1.0f / fsMeasured; hpA = rc / (rc + dt); barSetup(fsMeasured); } bool audioBegin() { adc_continuous_handle_cfg_t hcfg = {}; hcfg.max_store_buf_size = 16384; // ~400 ms Reserve, falls loop() aufgehalten wird hcfg.conv_frame_size = 256; // 128 Samples pro Frame if (adc_continuous_new_handle(&hcfg, &adcHandle) != ESP_OK) return false; adc_digi_pattern_config_t pat = {}; pat.atten = ADC_ATTEN_DB_12; // ca. 0,15..2,45 V, Bias 1,65 V #if AUDIO_SELFTEST pat.channel = ADC_CHANNEL_4; // GPIO 32 (Selbsttest) #else pat.channel = ADC_CHANNEL_6; // GPIO 34 #endif pat.unit = ADC_UNIT_1; pat.bit_width = SOC_ADC_DIGI_MAX_BITWIDTH; adc_continuous_config_t cfg = {}; cfg.pattern_num = 1; cfg.adc_pattern = &pat; cfg.sample_freq_hz = AUDIO_FS_HZ; cfg.conv_mode = ADC_CONV_SINGLE_UNIT_1; cfg.format = ADC_DIGI_OUTPUT_FORMAT_TYPE1; if (adc_continuous_config(adcHandle, &cfg) != ESP_OK) return false; if (adc_continuous_start(adcHandle) != ESP_OK) return false; fsT0 = millis(); audioSetup(); return true; } // Ein Goertzel-Block ist fertig: Magnitude bewerten, Schwelle nachführen void audioBlockDone() { float power = gS1 * gS1 + gS2 * gS2 - gCoeff * gS1 * gS2; if (power < 0) power = 0; audioMag = 2.0f * sqrtf(power) / gN; // ~ Amplitude in ADC-Counts dcEst = 0.9f * dcEst + 0.1f * (blockSum / gN); // Gleichanteil (Bias) nachführen // Tonanteil: Leistung der Zielfrequenz (A²/2) im Verhältnis zur Blockleistung. // Reiner Ton ~1, Knacken/Rauschen/Brumm deutlich kleiner. float var = blockEnergy / gN; tonality = var > 1.0f ? (audioMag * audioMag * 0.5f) / var : 0; gS1 = gS2 = 0; gCount = 0; blockSum = 0; blockEnergy = 0; // Spitzenwert: schnell hoch, langsam runter if (audioMag > peakMag) peakMag = audioMag; else peakMag += 0.002f * (noiseFloor - peakMag); // Rauschboden nur nachführen, wenn kein Ton anliegt if (!audioOn) { if (audioMag < noiseFloor) noiseFloor += 0.10f * (audioMag - noiseFloor); else noiseFloor += 0.01f * (audioMag - noiseFloor); } thrOn = noiseFloor * AUDIO_SNR_ON; float mid = noiseFloor + 0.5f * (peakMag - noiseFloor); if (mid > thrOn) thrOn = mid; if (thrOn < AUDIO_MIN_MAG) thrOn = AUDIO_MIN_MAG; thrOff = thrOn * AUDIO_HYST; if (thrOff < noiseFloor * 2.0f) thrOff = noiseFloor * 2.0f; bool was = audioOn; if (!audioOn && audioMag > thrOn && tonality > AUDIO_TONAL_ON) { audioOn = true; onEdges++; } else if (audioOn && (audioMag < thrOff || tonality < AUDIO_TONAL_HOLD)) audioOn = false; if (audioOn != was && aPendN < AEDGE_N) { aPendIdx[aPendN] = aProcIdx; aPendOn[aPendN] = audioOn; aPendN++; } } void printDecoderStatus(); void printAudioStatus(const char *tag) { float zcHz = zcSamples ? (zcCount / 2.0f) * fsMeasured / zcSamples : 0; Serial.printf("\n%s ton=%.2f mag=%.1f nf=%.1f peak=%.1f thrOn=%.1f thrOff=%.1f on=%d edges=%u dc=%.0f raw=%d..%d zcHz=%.0f fs=%.0f f=%d N=%d src=%s pk=%.1f dpk=%+.1f pkdb=%.1f nfdb=%.1f loopMax=%uus barMax=%uus lvl=%.0f clip=%d\n", tag, tonality, audioMag, noiseFloor, peakMag, thrOn, thrOff, audioOn ? 1 : 0, (unsigned)onEdges, dcEst, rawMin, rawMax, zcHz, fsMeasured, audioFreq, gN, keySource == 3 ? "both" : (keySource == 1 ? "key" : "audio"), barPeakValid ? barPeakHz : 0.0f, barPeakValid ? barPeakHz - audioFreq : 0.0f, barPeakDb, barNoiseDb, (unsigned)loopMaxUs, (unsigned)barMaxUs, (float)lvlAmp, lvlClipAny ? 1 : 0); lvlClipAny = false; loopMaxUs = 0; barMaxUs = 0; rawMin = 4095; rawMax = 0; zcCount = zcSamples = 0; onEdges = 0; printDecoderStatus(); } // Alle vorliegenden ADC-Daten abholen und verarbeiten (aus loop()) void audioLoop(uint32_t now) { if (!audioOk) return; static uint8_t buf[256]; uint32_t len = 0; aProcIdx = 0; aPendN = 0; while (adc_continuous_read(adcHandle, buf, sizeof(buf), &len, 0) == ESP_OK && len) { for (uint32_t i = 0; i + 1 < len; i += SOC_ADC_DIGI_RESULT_BYTES) { adc_digi_output_data_t *d = (adc_digi_output_data_t *)&buf[i]; if (d->type1.channel != pat_channel) continue; int raw = d->type1.data; if (dumpPos >= 0 && dumpPos < DUMP_LEN) dumpBuf[dumpPos++] = raw; if (raw < rawMin) rawMin = raw; if (raw > rawMax) rawMax = raw; // Hochpass 1. Ordnung entfernt Gleichanteil, Brumm und Knackflanken hpY = hpA * (hpY + raw - hpX); hpX = raw; float x = hpY; blockEnergy += x * x; aProcIdx++; // Nulldurchgänge mit Hysterese zählen -> gemessene Tonfrequenz if (zcPos && x < -40) { zcPos = false; zcCount++; } else if (!zcPos && x > 40) { zcPos = true; zcCount++; } zcSamples++; blockSum += raw; if (raw < lvlMin) lvlMin = raw; if (raw > lvlMax) lvlMax = raw; if (++lvlCnt >= LVL_WIN) lvlWindowDone(); float s0 = x + gCoeff * gS1 - gS2; gS2 = gS1; gS1 = s0; sampleCount++; if (++gCount >= gN) audioBlockDone(); if (barOn) barSample(x); } } // Flanken dieses Aufrufs mit Zeitstempel in die Warteschlange: der letzte // gelesene Abtastwert entspricht ungefähr "jetzt" for (int i = 0; i < aPendN; i++) { uint32_t back = (uint32_t)((aProcIdx - aPendIdx[i]) * 1000.0f / fsMeasured); int nh = (aEdgeHead + 1) % AEDGE_N; if (nh == aEdgeTail) break; // voll (sollte nicht vorkommen) aEdge[aEdgeHead].t = now - back; aEdge[aEdgeHead].on = aPendOn[i]; aEdgeHead = nh; } aPendN = 0; // tatsächliche Abtastrate messen (alle 2 s) und Filter anpassen // Abtastrate kumulativ seit Start/letztem Flush messen: Daten, die noch im // DMA-Puffer liegen (loop() kurz aufgehalten), verfälschen so nur minimal. // Filter (Goertzel, Bargraph) erst bei > 0,3 % Abweichung neu berechnen. if (now - fsT0 >= 5000 && now - fsLastChk >= 2000) { fsLastChk = now; float fs = sampleCount * 1000.0f / (now - fsT0); if (fs > 1000 && fabsf(fs - fsMeasured) > fsMeasured * 0.003f) { fsMeasured = fs; audioSetup(); } } if (audioDebug && now - lastDbg >= 100) { lastDbg = now; printAudioStatus("#A"); } if (dumpPos >= DUMP_LEN) { Serial.printf("\n#DUMP fs=%.0f n=%d\n", fsMeasured, DUMP_LEN); for (int i = 0; i < DUMP_LEN; i += 32) { Serial.print("#V"); for (int j = i; j < i + 32; j++) { Serial.print(' '); Serial.print(dumpBuf[j]); } Serial.println(); } Serial.println("#DUMP Ende"); dumpPos = -1; } } #if AUDIO_SELFTEST // Nicht blockierender Morse-Generator auf SELFTEST_PIN int stoneHz = 0; // /stone N: Dauerton N Hz statt Morse (0 = Morse) // Rechteck über LEDC direkt (zuverlässiger als tone()/noTone() bei schnellem Wechsel) void stTone(int hz) { static bool attached = false; if (!attached) { ledcAttach(SELFTEST_PIN, 1000, 8); attached = true; } if (hz > 0) ledcWriteTone(SELFTEST_PIN, hz); else ledcWrite(SELFTEST_PIN, 0); } void selftestLoop(uint32_t now) { static int playing = -1; if (stoneHz > 0) { if (playing != stoneHz) { stTone(stoneHz); playing = stoneHz; } return; } if (playing > 0) { stTone(0); playing = -1; } static const char *txt = "PARIS SOS 0123456789 "; static int ci = 0, ei = 0; // Zeichen- / Elementindex static bool on = false; static uint32_t next = 3000; // Start nach 3 s static const int wpms[3] = {12, 18, 25}; static int wi = 0; const uint32_t u = 1200 / wpms[wi]; if ((int32_t)(now - next) < 0) return; if (on) { // Element zu Ende -> Pause stTone(0); on = false; const char *code = encodeChar(txt[ci]); if (code && code[ei + 1]) { ei++; next = now + u; return; } ei = 0; ci++; // nächstes Zeichen next = now + 3 * u; // Zeichenpause if (txt[ci] == ' ') { ci++; next = now + 7 * u; } // Wortpause if (!txt[ci]) { // Textende: 3 s Pause ci = 0; next = now + 3000; wi = (wi + 1) % 3; Serial.printf("\n#T Selbsttest naechste Runde: %d WPM\n", wpms[wi]); } return; } const char *code = encodeChar(txt[ci]); if (!code) { ci = (ci + 1) % strlen(txt); return; } stTone(700); on = true; next = now + (code[ei] == '-' ? 3 * u : u); } #endif // Nach blockierenden Aktionen (Geber) alte ADC-Daten verwerfen void audioFlush() { if (!audioOk) return; static uint8_t buf[256]; uint32_t len = 0; while (adc_continuous_read(adcHandle, buf, sizeof(buf), &len, 0) == ESP_OK && len) {} gS1 = gS2 = 0; gCount = 0; blockSum = 0; audioOn = false; aEdgeTail = aEdgeHead; aPendN = 0; audioState = false; sampleCount = 0; fsT0 = millis(); } // ========================================================================== // DECODER // ========================================================================== char symBuf[10]; // aktuelle Punkte/Striche (Anzeige) int symLen = 0; uint32_t lastRelease = 0; // Ende des letzten Elements uint32_t pressStart = 0; // Beginn des aktuellen Elements bool wordSpacePending = false; // letztes Zeichen fertig, Wortpause noch offen // Tempo-Schätzung: // - Dauer der Elemente des aktuellen Zeichens (endgültige Punkt/Strich- // Entscheidung erst am Zeichenende -> auch das erste Zeichen nach einem // Tempowechsel wird richtig erkannt) // - Verlauf der letzten 16 Elementdauern: zwei Gruppen (Punkte/Striche) // - Pausen innerhalb eines Zeichens (= 1 Einheit) als Referenz, wenn ein // Zeichen nur aus Punkten oder nur aus Strichen besteht (S, O, 0, 5 ...) // - Pausen innerhalb des aktuellen Zeichens werden mitgespeichert: stellt // sich am Zeichenende (mit neuer Tempo-Schätzung) heraus, dass eine davon // eine Zeichenpause war, wird das Zeichen nachträglich geteilt // (z. B. "TH" bei Tempowechsel nicht mehr als "6") // - Pausen zwischen Zeichen werden in Einheiten gespeichert (12 Stück) und in // zwei Gruppen geteilt (Zeichen- / Wortpause). Damit funktioniert auch // Farnsworth-Timing (gedehnte Pausen). Das Leerzeichen wird deshalb erst // beim Beginn des nächsten Zeichens ausgegeben, wenn die Pause bekannt ist. float thrMs = 2.0f * 1200.0f / START_WPM; // Grenze Punkt/Strich float durBuf[9]; int durLen = 0; float cgBuf[8]; // Pausen im aktuellen Zeichen float hist[16]; int histLen = 0, histPos = 0; float gapIn[8]; int gapLen = 0, gapPos = 0; float gapEma = 1200.0f / START_WPM; // schneller Mittelwert der Pausen im Zeichen float cgU[12]; int cgLen = 0, cgPos = 0; // Pausen zwischen Zeichen (Einheiten) void estimateSpeed() { if (histLen == 0) return; float mn = 1e9, mx = 0, sum = 0; for (int i = 0; i < histLen; i++) { mn = min(mn, hist[i]); mx = max(mx, hist[i]); sum += hist[i]; } if (mx >= 2.0f * mn) { // zwei Gruppen: Punkte und Striche float mid = (mn + mx) / 2, ds = 0, Ds = 0; int dn = 0, Dn = 0; for (int i = 0; i < histLen; i++) { if (hist[i] < mid) { ds += hist[i]; dn++; } else { Ds += hist[i]; Dn++; } } if (dn == 0 || Dn == 0) return; float da = ds / dn, Da = Ds / Dn; thrMs = (da + Da) / 2; unitMs = (da + Da / 3) / 2; } else { // nur eine Gruppe: Punkte oder Striche? float avg = sum / histLen; if (gapLen > 0) { float g = 0; for (int i = 0; i < gapLen; i++) g += gapIn[i]; g /= gapLen; unitMs = (avg > 2.0f * g) ? avg / 3 : avg; } else if (avg <= 1.6f * unitMs) { unitMs = avg; // gleich schnell oder schneller -> Punkte } // sonst mehrdeutig: Schätzung beibehalten thrMs = 2.0f * unitMs; } unitMs = constrain(unitMs, 1200.0f / MAX_WPM, 1200.0f / MIN_WPM); } // Schwelle Zeichenende: 2,5 Einheiten. Werden die Pausen im Zeichen deutlich // kürzer (Tempo steigt), gilt sofort deren Mittelwert als Einheit. float charGapMs() { float base = (gapEma < 0.7f * unitMs) ? gapEma : unitMs; return CHAR_GAP_UNITS * base; } // Schwelle Wortpause in Einheiten aus den letzten Pausen zwischen Zeichen float wordGapUnits() { if (cgLen == 0) return WORD_GAP_UNITS; float mn = 1e9, mx = 0; for (int i = 0; i < cgLen; i++) { mn = min(mn, cgU[i]); mx = max(mx, cgU[i]); } if (cgLen >= 2 && mx >= 1.8f * mn) { float mid = (mn + mx) / 2, a = 0, b = 0; int na = 0, nb = 0; for (int i = 0; i < cgLen; i++) { if (cgU[i] < mid) { a += cgU[i]; na++; } else { b += cgU[i]; nb++; } } return (a / na + b / nb) / 2; // Mitte zwischen Zeichen- und Wortpausen } return (mn > 5.0f) ? 9.0f : WORD_GAP_UNITS;// nur eine Gruppe: gedehnt (Farnsworth?) oder normal } void printDecoderStatus() { Serial.printf("#D key=%d since=%u lastRel=%u durLen=%d histLen=%d gapLen=%d cgLen=%d unit=%.1f thr=%.1f wpm=%.1f gapEma=%.1f wordU=%.1f\n", keyBtn.stable ? 1 : 0, (unsigned)keyBtn.since, (unsigned)lastRelease, durLen, histLen, gapLen, cgLen, unitMs, thrMs, 1200.0f / unitMs, gapEma, wordGapUnits()); } // Nach langer Pause: Verlauf vergessen und Tempo auf den Startwert (mittleres // Tempo) zurücksetzen – so wird das erste Zeichen nach einem Tempowechsel in // beide Richtungen (schneller/langsamer) am zuverlässigsten erkannt. void resetSpeedHistory() { histLen = histPos = 0; gapLen = gapPos = 0; cgLen = cgPos = 0; unitMs = 1200.0f / START_WPM; thrMs = 2.0f * unitMs; gapEma = unitMs; } // Ein Teilstück (Elemente durBuf[from..to-1], Pausen cgBuf[from..to-2]) // dekodieren und ausgeben. Punkt/Strich-Grenze: // - Zeichen enthält deutlich kurze und lange Elemente -> Grenze aus dem // Zeichen selbst (robust direkt nach Tempowechseln) // - alle Elemente gleich lang -> Vergleich mit den Pausen im Zeichen // (Strich = 3 Einheiten, Pause = 1 Einheit) // - einzelnes Element ohne Vergleich -> Verhältnis zur Zeichenpause // - sonst globale Schwelle aus dem Verlauf void emitSegment(int from, int to, float endGap) { int n = to - from; float thr = thrMs, mn = 1e9, mx = 0, sum = 0; for (int i = from; i < to; i++) { mn = min(mn, durBuf[i]); mx = max(mx, durBuf[i]); sum += durBuf[i]; } if (n > 1 && mx >= 2.2f * mn) { thr = (mn + mx) / 2; } else if (n > 1 && mx < 1.5f * mn) { float g = 0; int gn = 0; for (int i = from; i < to - 1 && i < 8; i++) { g += cgBuf[i]; gn++; } if (gn) thr = (sum / n > 1.7f * (g / gn)) ? 0 : 1e9; } else if (n == 1 && endGap > 0 && gapLen == 0 && histLen > 0) { float hmn = 1e9, hmx = 0; for (int i = 0; i < histLen; i++) { hmn = min(hmn, hist[i]); hmx = max(hmx, hist[i]); } if (hmx < 2.0f * hmn) thr = 0.6f * endGap; } for (int i = 0; i < n; i++) symBuf[i] = durBuf[from + i] > thr ? '-' : '.'; symBuf[n] = 0; char c = decodeMorse(symBuf); Serial.print(c); appendChar(c, C_TEXT); } void finishCharacter(float endGap = 0) { if (durLen == 0) return; estimateSpeed(); // Nachträglich teilen, wenn eine Pause im Zeichen eine Zeichenpause war float mnGap = 1e9; for (int i = 0; i < durLen - 1; i++) mnGap = min(mnGap, cgBuf[i]); float base = unitMs; if (durLen > 1) base = min(unitMs, max(mnGap, 0.7f * unitMs)); int from = 0; for (int i = 0; i < durLen - 1; i++) { if (cgBuf[i] > CHAR_GAP_UNITS * base) { emitSegment(from, i + 1, 0); from = i + 1; } } emitSegment(from, durLen, endGap); durLen = symLen = 0; drawSymbols(""); drawHeader(); // WPM aktualisieren wordSpacePending = true; // nach einem Zeichen kann ein Wortende kommen } // Taste losgelassen / Ton zu Ende: Element speichern, Tempo neu schätzen void handleElement(uint32_t dur) { if (durLen < 9) durBuf[durLen++] = dur; hist[histPos] = dur; histPos = (histPos + 1) % 16; if (histLen < 16) histLen++; estimateSpeed(); // vorläufige Anzeige symLen = durLen; for (int i = 0; i < durLen; i++) symBuf[i] = durBuf[i] > thrMs ? '-' : '.'; symBuf[symLen] = 0; drawSymbols(symBuf); } // Übergang des kombinierten Signals (Taste ODER Ton) zum Zeitpunkt t void keyTransition(bool down, uint32_t t, bool phys) { keyBtn.stable = down; keyBtn.since = t; if (down) { // gedrückt / Ton erkannt if (phys || AUDIO_SIDETONE) signalOn(); pressStart = t; if (lastRelease) { float g = (int32_t)(t - lastRelease) > 0 ? t - lastRelease : 0; if (durLen > 0) { // Zeichen noch offen -> Pause innerhalb des Zeichens if (durLen <= 8) cgBuf[durLen - 1] = g; gapIn[gapPos] = g; gapPos = (gapPos + 1) % 8; if (gapLen < 8) gapLen++; gapEma = 0.5f * gapEma + 0.5f * g; } else if (wordSpacePending) { // Pause zwischen zwei Zeichen: jetzt ist ihre Länge bekannt if (g <= max(15.0f * unitMs, 1500.0f)) { cgU[cgPos] = g / unitMs; cgPos = (cgPos + 1) % 12; if (cgLen < 12) cgLen++; } if (g > wordGapUnits() * unitMs) { Serial.print(' '); appendChar(' ', C_TEXT); } wordSpacePending = false; } } } else { // losgelassen signalOff(); handleElement(t - pressStart); lastRelease = t; } } // Pausen bis zum Zeitpunkt t auswerten (Zeichenende, lange Pause) void checkGaps(uint32_t t) { if (!keyBtn.stable && lastRelease && (int32_t)(t - lastRelease) > 0) { uint32_t gap = t - lastRelease; if (durLen > 0 && gap > charGapMs()) finishCharacter(gap); // lange Pause (Sprecherwechsel/Tempowechsel möglich): Verlauf vergessen if (histLen > 0 && gap > max(15.0f * unitMs, 1500.0f)) resetSpeedHistory(); } } void decoderLoop(uint32_t now) { // 1) Ton-Flanken mit ihren echten Zeitpunkten abarbeiten while (aEdgeTail != aEdgeHead) { AudioEdge e = aEdge[aEdgeTail]; aEdgeTail = (aEdgeTail + 1) % AEDGE_N; audioState = e.on; if (!(keySource & SRC_AUDIO)) continue; bool comb = ((keySource & SRC_KEY) && physBtn.stable) || audioState; checkGaps(e.t); // Pause vor dieser Flanke zuerst auswerten if (comb != keyBtn.stable) keyTransition(comb, e.t, false); } // 2) Morsetaste (entprellt) bool phys = (digitalRead(PIN_KEY) == LOW); if (updateButtonRaw(physBtn, phys, now)) { bool comb = ((keySource & SRC_KEY) && physBtn.stable) || ((keySource & SRC_AUDIO) && audioState); checkGaps(now); if (comb != keyBtn.stable) keyTransition(comb, now, physBtn.stable); } // 3) Pausen bis jetzt (abzüglich Audio-Latenz, damit eine noch nicht // erkannte Ton-Flanke nicht fälschlich als Zeichenende gilt) checkGaps(now - AUDIO_LAT_MS); } // ========================================================================== // GEBER // ========================================================================== // Wartet ms Millisekunden; bricht ab (return false), wenn eine Taste gedrückt wird bool waitAbortable(uint32_t ms) { uint32_t t0 = millis(); while (millis() - t0 < ms) { if (digitalRead(PIN_KEY) == LOW || digitalRead(PIN_FUNC) == LOW) return false; delay(1); } return true; } // Ein Element (Punkt oder Strich) ausgeben bool sendElement(uint32_t ms) { signalOn(); ledSet(true); bool ok = waitAbortable(ms); signalOff(); ledSet(false); return ok; } // Text als Morse ausgeben (Summer + LED + Anzeige) void sendText(const String &txt) { uint32_t unit = 1200 / txWpm; Serial.print(F("[TX ")); Serial.print(txWpm); Serial.print(F(" WPM] ")); appendChar(' ', C_SENT); for (unsigned i = 0; i < txt.length(); i++) { char c = txt[i]; // einfache UTF-8-Umlaute -> zwei Buchstaben (Ä->AE ...), ß -> SS String part; if ((uint8_t)c == 0xC3 && i + 1 < txt.length()) { uint8_t n = (uint8_t)txt[++i]; if (n == 0x84 || n == 0xA4) part = "AE"; else if (n == 0x96 || n == 0xB6) part = "OE"; else if (n == 0x9C || n == 0xBC) part = "UE"; else if (n == 0x9F) part = "SS"; } else { part = String(c); } for (unsigned k = 0; k < part.length(); k++) { char ch = toupper((unsigned char)part[k]); if (ch == ' ') { // Wortpause: 7 Einheiten (3 schon vorbei + 4) Serial.print(F("/ ")); appendChar(' ', C_SENT); if (!waitAbortable(4 * unit)) goto abort; continue; } const char *code = encodeChar(ch); if (!code) continue; // unbekanntes Zeichen überspringen Serial.print(code); Serial.print(' '); drawSymbols(code); for (const char *p = code; *p; p++) { if (!sendElement(*p == '-' ? 3 * unit : unit)) goto abort; if (*(p + 1) && !waitAbortable(unit)) goto abort; // Pause im Zeichen } appendChar(ch, C_SENT); if (!waitAbortable(3 * unit)) goto abort; // Pause nach Zeichen } } drawSymbols(""); Serial.println(F("[TX Ende]")); return; abort: signalOff(); ledSet(false); drawSymbols(""); Serial.println(F("\n[TX abgebrochen]")); // warten, bis Tasten losgelassen sind, damit der Decoder nichts falsch liest while (digitalRead(PIN_KEY) == LOW || digitalRead(PIN_FUNC) == LOW) delay(5); delay(50); } void printHelp() { Serial.println(F("\n=== Morse-Decoder/-Geber ESP32 ===")); Serial.println(F("Text eingeben + Enter -> wird gemorst")); Serial.println(F("/wpm N Geber-Tempo (5..40)")); Serial.println(F("/ton N Tonhöhe Hz (300..1500)")); Serial.println(F("/freq N Audio-Zielfrequenz Hz (300..1500)")); Serial.println(F("/audio Audio-Pegel-Debug an/aus")); Serial.println(F("/src X Quelle: key | audio | both")); Serial.println(F("/stat Audio-Status einmal ausgeben")); Serial.println(F("/bar on|off Bargraph-Abstimmanzeige (Peak-Frequenz auch in /audio: pk=, dpk=)")); Serial.println(F("/clear Text löschen")); Serial.println(F("/help diese Hilfe")); Serial.println(F("Morsetaste GPIO27, Funktionstaste GPIO33 (kurz=löschen, lang=Echo), Audio GPIO34\n")); } // Serielle Eingabe zeilenweise lesen und auswerten String inLine; void serialLoop() { while (Serial.available()) { char c = Serial.read(); if (c == '\r') continue; if (c != '\n') { if (inLine.length() < 200) inLine += c; continue; } String line = inLine; inLine = ""; line.trim(); if (line.length() == 0) continue; if (line.startsWith("/wpm")) { int v = line.substring(4).toInt(); if (v >= MIN_WPM && v <= MAX_WPM) { txWpm = v; drawHeader(); } Serial.printf("Geber-Tempo: %d WPM\n", txWpm); } else if (line.startsWith("/ton")) { int v = line.substring(4).toInt(); if (v >= 300 && v <= 1500) toneHz = v; Serial.printf("Tonhöhe: %d Hz\n", toneHz); } else if (line.startsWith("/freq")) { int v = line.substring(5).toInt(); if (v >= 300 && v <= 1500) { audioFreq = v; audioSetup(); } Serial.printf("Audio-Zielfrequenz: %d Hz\n", audioFreq); } else if (line == "/audio") { audioDebug = !audioDebug; Serial.printf("Audio-Debug %s\n", audioDebug ? "an" : "aus"); } else if (line.startsWith("/bar")) { String m = line.substring(4); m.trim(); if (m == "on" || m == "off") { bool on = (m == "on"); if (on != barOn) { barOn = on; if (on) barSetup(fsMeasured); layoutScreen(); } } Serial.printf("Bargraph %s\n", barOn ? "an" : "aus"); #if AUDIO_SELFTEST } else if (line.startsWith("/stone")) { stoneHz = line.substring(6).toInt(); if (stoneHz < 0 || stoneHz > 5000) stoneHz = 0; Serial.printf("Selbsttest-Ton: %d Hz\n", stoneHz); #endif } else if (line == "/dump") { dumpPos = 0; } else if (line == "/stat") { printAudioStatus("#S"); } else if (line.startsWith("/src")) { String m = line.substring(4); m.trim(); if (m == "key") keySource = SRC_KEY; else if (m == "audio") keySource = SRC_AUDIO; else if (m == "both") keySource = SRC_KEY | SRC_AUDIO; Serial.printf("Quelle: %s\n", keySource == 3 ? "both" : (keySource == 1 ? "key" : "audio")); } else if (line == "/clear") { durLen = symLen = 0; drawSymbols(""); clearText(); } else if (line == "/help" || line == "?") { printHelp(); } else { finishCharacter(); // evtl. halbes Empfangszeichen abschließen sendText(line); audioFlush(); lastRelease = 0; // Decoder-Pausen zurücksetzen wordSpacePending = false; } } } // Funktionstaste: kurz = löschen, lang = Echo des Textes uint32_t funcPressedAt = 0; void funcButtonLoop(uint32_t now) { if (updateButton(funcBtn, now)) { if (funcBtn.stable) { funcPressedAt = now; } else { uint32_t held = now - funcPressedAt; if (held >= LONG_PRESS_MS) { // Echo: empfangenen Text erneut senden String s; for (int i = 0; i < textLen; i++) s += textBuf[i]; s.trim(); if (s.length()) { finishCharacter(); sendText(s); audioFlush(); lastRelease = 0; wordSpacePending = false; } } else { symLen = durLen = 0; drawSymbols(""); clearText(); } } } } // ========================================================================== // SETUP / LOOP // ========================================================================== void setup() { Serial.begin(SERIAL_BAUD); pinMode(PIN_KEY, INPUT_PULLUP); pinMode(PIN_FUNC, INPUT_PULLUP); pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, LOW); #if PIN_LED >= 0 pinMode(PIN_LED, OUTPUT); digitalWrite(PIN_LED, LOW); #endif setupDisplay(); audioOk = audioBegin(); #if AUDIO_SELFTEST Serial.println(F("*** AUDIO-SELBSTTEST: ADC an GPIO 32, Generator 700 Hz auf GPIO 32 ***")); #endif Serial.printf("Audio-Eingang GPIO34: %s\n", audioOk ? "aktiv (20 kHz, Goertzel)" : "FEHLER beim ADC-Start"); delay(200); printHelp(); } void loop() { uint32_t t0us = micros(); uint32_t now = millis(); audioLoop(now); #if AUDIO_SELFTEST selftestLoop(now); #endif decoderLoop(now); uint32_t tb = micros(); if (barOn) lvlDraw(now); barDraw(now); uint32_t db = micros() - tb; if (db > barMaxUs) barMaxUs = db; funcButtonLoop(now); // Laufzeit ohne serialLoop() (Befehle/Statusausgaben sind vom Benutzer ausgelöst) uint32_t dt = micros() - t0us; if (dt > loopMaxUs) loopMaxUs = dt; serialLoop(); }