From e50ad54021fac86470b5361601aaea719d51da42 Mon Sep 17 00:00:00 2001 From: Jannis Date: Thu, 24 Sep 2026 00:02:27 +0200 Subject: [PATCH] minimal besser --- .gitignore | 5 + RTSPMicesp32c6.ino | 308 ++++++++++++++++++++------------------------- birdnet-mic.yaml | 45 +++++++ 3 files changed, 183 insertions(+), 175 deletions(-) create mode 100644 .gitignore create mode 100644 birdnet-mic.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8b4157 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/RTSPMicesp32c6.ino b/RTSPMicesp32c6.ino index a33cef2..ab63bdd 100644 --- a/RTSPMicesp32c6.ino +++ b/RTSPMicesp32c6.ino @@ -4,41 +4,58 @@ // --- WLAN-Konfiguration --- const char* ssid = "Stockfeld"; const char* password = "70691554384740972413"; // Hier dein echtes Passwort eintragen +#define HTTP_PORT 8080 +#define SAMPLE_RATE 48000 +// 480 Samples = 960 Bytes (Extrem sichere Größe für den WLAN-Puffer) +#define SAMPLES_PER_PKT 480 -// --- Audio- & I2S-Parameter --- -#define SAMPLE_RATE 48000 -#define SAMPLES_PER_PKT 480 // 10 ms Audio pro Frame (verhindert IP-Fragmentierung) +#define PIN_I2S_BCLK GPIO_NUM_0 // D0 +#define PIN_I2S_WS GPIO_NUM_1 // D1 +#define PIN_I2S_DIN GPIO_NUM_2 // D2 -#define PIN_I2S_BCLK GPIO_NUM_0 // D0 -#define PIN_I2S_WS GPIO_NUM_1 // D1 -#define PIN_I2S_DIN GPIO_NUM_2 // D2 +#define DIGITAL_GAIN 1 -// Optionaler digitaler Gain nach korrektem 16-Bit-Downsampling (1 = kein Gain). -// Vorsicht: >1 kann bei lauten Signalen übersteuern, wird aber sauber geclippt. -#define DIGITAL_GAIN 1 +// 100 Chunks = exakt 1 Sekunde Audio-Puffer im RAM +#define AUDIO_QUEUE_LEN 100 -WiFiServer rtspServer(8554); -WiFiClient rtspClient; +WiFiServer httpServer(HTTP_PORT); +WiFiClient httpClient; i2s_chan_handle_t rx_chan = NULL; -uint16_t rtpSeqNum = 0; -uint32_t rtpTimestamp = 0; -const uint32_t rtpSSRC = 0x12345678; -bool isStreaming = false; +volatile bool isStreaming = false; + +struct AudioChunk { + uint16_t sampleCount; + int16_t samples[SAMPLES_PER_PKT]; +}; + +QueueHandle_t audioQueue; + +const uint8_t wavHeader[44] = { + 'R', 'I', 'F', 'F', + 0xFF, 0xFF, 0xFF, 0xFF, + 'W', 'A', 'V', 'E', + 'f', 'm', 't', ' ', + 16, 0, 0, 0, + 1, 0, + 1, 0, + 0x80, 0xBB, 0x00, 0x00, + 0x00, 0x77, 0x01, 0x00, + 2, 0, + 16, 0, + 'd', 'a', 't', 'a', + 0xFF, 0xFF, 0xFF, 0xFF +}; void setupI2S() { i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); chan_cfg.dma_desc_num = 6; chan_cfg.dma_frame_num = 240; - esp_err_t err = i2s_new_channel(&chan_cfg, NULL, &rx_chan); - if (err != ESP_OK) { - Serial.printf("FEHLER: i2s_new_channel() fehlgeschlagen: %s\n", esp_err_to_name(err)); - } + i2s_new_channel(&chan_cfg, NULL, &rx_chan); i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE), - // ICS-43434 liefert 24-bit-Samples, MSB-aligned in einem 32-bit-Wort: .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO), .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, @@ -49,196 +66,137 @@ void setupI2S() { .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false } } }; - // WICHTIG: Der L/R-Pin des ICS-43434 muss fest auf GND liegen, damit dieser - // Slot (links) tatsächlich die Mikrofondaten enthält. Liegt L/R auf 3.3V, - // hier auf I2S_STD_SLOT_RIGHT umstellen. std_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_LEFT; - err = i2s_channel_init_std_mode(rx_chan, &std_cfg); - if (err != ESP_OK) { - Serial.printf("FEHLER: i2s_channel_init_std_mode() fehlgeschlagen: %s\n", esp_err_to_name(err)); - } + i2s_channel_init_std_mode(rx_chan, &std_cfg); + i2s_channel_enable(rx_chan); +} - err = i2s_channel_enable(rx_chan); - if (err != ESP_OK) { - Serial.printf("FEHLER: i2s_channel_enable() fehlgeschlagen: %s\n", esp_err_to_name(err)); +void i2sReaderTask(void *param) { + static int32_t i2s_raw[SAMPLES_PER_PKT]; + AudioChunk chunk; + + for (;;) { + size_t bytes_read = 0; + esp_err_t res = i2s_channel_read(rx_chan, i2s_raw, sizeof(i2s_raw), &bytes_read, portMAX_DELAY); + if (res != ESP_OK || bytes_read == 0) continue; + + int samples = bytes_read / sizeof(int32_t); + chunk.sampleCount = samples; + + for (int i = 0; i < samples; i++) { + int32_t val = (i2s_raw[i] >> 16) * DIGITAL_GAIN; + if (val > 32767) val = 32767; + if (val < -32768) val = -32768; + chunk.samples[i] = (int16_t)val; + } + + if (xQueueSend(audioQueue, &chunk, 0) != pdTRUE) { + AudioChunk dummy; + xQueueReceive(audioQueue, &dummy, 0); + xQueueSend(audioQueue, &chunk, 0); + } } } -void handleRTSP() { - if (rtspServer.hasClient()) { - WiFiClient newClient = rtspServer.accept(); +void handleHTTP() { + if (httpServer.hasClient()) { + WiFiClient newClient = httpServer.accept(); if (newClient) { - if (rtspClient) rtspClient.stop(); - rtspClient = newClient; - rtspClient.setTimeout(50); // Timeout auf 50 ms senken, um Blockaden zu verhindern + if (httpClient) httpClient.stop(); + httpClient = newClient; + + // 3 Sekunden Timeout auf Socket-Ebene + httpClient.setTimeout(3000); + isStreaming = false; + Serial.printf("Client verbunden: %s (Heap: %u Bytes)\n", + httpClient.remoteIP().toString().c_str(), ESP.getFreeHeap()); } } - if (!rtspClient || !rtspClient.connected()) return; - - if (rtspClient.available()) { - String req = rtspClient.readStringUntil('\n'); - req.trim(); - if (req.length() == 0) return; - - int cseq = 1; - // Robuster Header-Parser: Wartet bis zum Ende der HTTP/RTSP-Header - while (rtspClient.connected()) { - String line = rtspClient.readStringUntil('\n'); - line.trim(); - if (line.length() == 0) break; - - if (line.startsWith("CSeq:")) { - cseq = line.substring(5).toInt(); - } - } - - if (req.startsWith("OPTIONS")) { - String resp = "RTSP/1.0 200 OK\r\n" - "CSeq: " + String(cseq) + "\r\n" - "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN, GET_PARAMETER\r\n\r\n"; - rtspClient.print(resp); - } - else if (req.startsWith("DESCRIBE")) { - String ipStr = WiFi.localIP().toString(); - // RFC-konformes SDP mit Verbindungszeile (c=) - String sdp = "v=0\r\n" - "o=- 0 0 IN IP4 " + ipStr + "\r\n" - "s=ESP32-BirdNET-Mic\r\n" - "c=IN IP4 " + ipStr + "\r\n" - "t=0 0\r\n" - "m=audio 0 RTP/AVP 96\r\n" - "a=rtpmap:96 L16/48000/1\r\n" - "a=control:*\r\n"; - - String resp = "RTSP/1.0 200 OK\r\n" - "CSeq: " + String(cseq) + "\r\n" - "Content-Type: application/sdp\r\n" - "Content-Base: rtsp://" + ipStr + ":8554/stream/\r\n" - "Content-Length: " + String(sdp.length()) + "\r\n\r\n" + sdp; - rtspClient.print(resp); - } - else if (req.startsWith("SETUP")) { - // TCP Interleaved zwingend vorgeben (Docker-Bridge kompatibel). - // Hinweis: BirdNET-Go/ffmpeg sollte den Stream mit - // "-rtsp_transport tcp" konsumieren, damit es hier keinen - // Mismatch mit einer eventuell angefragten UDP-Transport gibt. - String resp = "RTSP/1.0 200 OK\r\n" - "CSeq: " + String(cseq) + "\r\n" - "Session: 12345678\r\n" - "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"; - rtspClient.print(resp); - } - else if (req.startsWith("PLAY")) { - String resp = "RTSP/1.0 200 OK\r\n" - "CSeq: " + String(cseq) + "\r\n" - "Session: 12345678\r\n" - "Range: npt=0.000-\r\n\r\n"; - rtspClient.print(resp); - isStreaming = true; - } - else if (req.startsWith("TEARDOWN")) { - rtspClient.print("RTSP/1.0 200 OK\r\nCSeq: " + String(cseq) + "\r\n\r\n"); + if (!httpClient || !httpClient.connected()) { + if (isStreaming) { + Serial.println("Client getrennt."); isStreaming = false; } - else if (req.startsWith("GET_PARAMETER")) { - // Wird von ffmpeg/VLC oft als Keepalive während des Streamings - // gesendet. Unbeantwortet würde der Client in ein Timeout laufen - // und ggf. die Verbindung neu aufbauen. - rtspClient.print("RTSP/1.0 200 OK\r\nCSeq: " + String(cseq) + "\r\n\r\n"); + return; + } + + if (!isStreaming && httpClient.available()) { + String req = httpClient.readStringUntil('\n'); + req.trim(); + + while (httpClient.connected()) { + String line = httpClient.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) break; } - else { - // Unbekannte Methode: trotzdem höflich antworten statt zu schweigen. - String resp = "RTSP/1.0 501 Not Implemented\r\n" - "CSeq: " + String(cseq) + "\r\n\r\n"; - rtspClient.print(resp); + + if (req.startsWith("GET")) { + httpClient.print("HTTP/1.0 200 OK\r\n" + "Content-Type: audio/wav\r\n" + "Connection: keep-alive\r\n\r\n"); + + httpClient.write(wavHeader, sizeof(wavHeader)); + xQueueReset(audioQueue); + isStreaming = true; + Serial.println("Streaming aktiv (Sende-Schleife aktiviert)."); } } } void streamAudio() { - if (!isStreaming || !rtspClient || !rtspClient.connected()) return; + if (!isStreaming || !httpClient || !httpClient.connected()) return; - static int32_t i2s_raw[SAMPLES_PER_PKT]; - size_t bytes_read = 0; + AudioChunk chunk; + if (xQueueReceive(audioQueue, &chunk, pdMS_TO_TICKS(100)) == pdTRUE) { + size_t bytesToSend = chunk.sampleCount * sizeof(int16_t); + const uint8_t* p = (const uint8_t*)chunk.samples; + size_t remaining = bytesToSend; - // DMA liest 480 Samples (10 ms Audio) blockierend mit 20 ms Timeout - esp_err_t res = i2s_channel_read(rx_chan, i2s_raw, sizeof(i2s_raw), &bytes_read, pdMS_TO_TICKS(20)); - if (res != ESP_OK || bytes_read == 0) return; - - int samples = bytes_read / sizeof(int32_t); - static uint8_t packet[4 + 12 + SAMPLES_PER_PKT * 2]; // 4B TCP-Frame + 12B RTP-Header + Audio-Payload - - // Konvertierung von 32-Bit I2S (24-bit-Sample, MSB-aligned) auf 16-Bit Big-Endian (RFC 3550 L16). - // WICHTIG: >>16 extrahiert die oberen 16 Bit des links-bündigen 24-Bit-Samples korrekt. - // Ein optionaler digitaler Gain wird NACH dem Downsampling angewendet und sauber geclippt, - // statt die Rohdaten vor dem int16-Cast zu verstärken (das würde bei Übersteuerung - // zu Wrap-around-Artefakten statt sauberem Clipping führen). - for (int i = 0; i < samples; i++) { - int32_t sample = (i2s_raw[i] >> 16) * DIGITAL_GAIN; - if (sample > 32767) sample = 32767; - if (sample < -32768) sample = -32768; - int16_t s16 = (int16_t)sample; - - packet[16 + i * 2] = (s16 >> 8) & 0xFF; // MSB - packet[16 + i * 2 + 1] = s16 & 0xFF; // LSB - } - - uint16_t rtpSize = 12 + samples * 2; - - // 4-Byte TCP Interleaved Header ($ = 0x24, Channel 0) - packet[0] = '$'; - packet[1] = 0; - packet[2] = (rtpSize >> 8) & 0xFF; - packet[3] = rtpSize & 0xFF; - - // 12-Byte RTP-Header - packet[4] = 0x80; - packet[5] = 96 & 0x7F; // Dynamischer Payload-Typ 96 - packet[6] = (rtpSeqNum >> 8) & 0xFF; - packet[7] = rtpSeqNum & 0xFF; - packet[8] = (rtpTimestamp >> 24) & 0xFF; - packet[9] = (rtpTimestamp >> 16) & 0xFF; - packet[10] = (rtpTimestamp >> 8) & 0xFF; - packet[11] = rtpTimestamp & 0xFF; - packet[12] = (rtpSSRC >> 24) & 0xFF; - packet[13] = (rtpSSRC >> 16) & 0xFF; - packet[14] = (rtpSSRC >> 8) & 0xFF; - packet[15] = rtpSSRC & 0xFF; - - rtpSeqNum++; - rtpTimestamp += samples; - - // In einem Frame absenden, um Fragmentierung zu vermeiden. - // Rückgabewert prüfen: bei vollem TCP-Sendepuffer werden sonst - // stillschweigend Pakete verworfen/unvollständig gesendet. - size_t written = rtspClient.write(packet, 4 + rtpSize); - if (written != (size_t)(4 + rtpSize)) { - Serial.printf("WARNUNG: Nur %u von %u Bytes gesendet (TCP-Puffer voll?)\n", - (unsigned)written, (unsigned)(4 + rtpSize)); + // KERN-FIX: Sende-Schleife! Behandelt 'Partial Writes' nun sauber, + // ohne die Verbindung versehentlich selbst zu kappen. + while (remaining > 0 && httpClient.connected()) { + size_t written = httpClient.write(p, remaining); + + if (written == 0) { + Serial.println("Fataler Netzwerk-Timeout -> Abbruch."); + httpClient.stop(); + isStreaming = false; + return; + } + + p += written; + remaining -= written; + } } } void setup() { Serial.begin(115200); + delay(200); + setupI2S(); WiFi.mode(WIFI_STA); - WiFi.setSleep(false); // Verhindert Modem-Sleep und Ping-Latenzspitzen WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(250); } - rtspServer.begin(); - Serial.print("RTSP URL: rtsp://"); - Serial.print(WiFi.localIP()); - Serial.println(":8554/stream"); + WiFi.setSleep(false); + + audioQueue = xQueueCreate(AUDIO_QUEUE_LEN, sizeof(AudioChunk)); + + xTaskCreate(i2sReaderTask, "i2s_reader", 8192, NULL, 3, NULL); + + httpServer.begin(); + Serial.printf("\n=== HTTP Streamer bereit: http://%s:%d/stream ===\n", + WiFi.localIP().toString().c_str(), HTTP_PORT); } void loop() { - handleRTSP(); + handleHTTP(); streamAudio(); -} +} \ No newline at end of file diff --git a/birdnet-mic.yaml b/birdnet-mic.yaml new file mode 100644 index 0000000..e488884 --- /dev/null +++ b/birdnet-mic.yaml @@ -0,0 +1,45 @@ +substitutions: + name: "birdnet-mic" + friendly_name: "BirdNET ESP32-C6 Mikrofon" + +esphome: + name: ${name} + friendly_name: ${friendly_name} + +esp32: + board: esp32-c6-devkitc-1 + variant: esp32c6 + framework: + type: esp-idf + +wifi: + ssid: "Stockfeld" + password: "70691554384740972413" + # Zwingend nötig: Verhindert Latenzspitzen und Paketverluste im WLAN + power_save_mode: none + +# Native API für den Audioabgriff +api: + reboot_timeout: 0s + +ota: + - platform: esphome + +logger: + level: INFO + +# I2S Audio Bus (Master-Modus) +i2s_audio: + - id: i2s_bus + i2s_lrclk_pin: GPIO1 # WS / D1 + i2s_bclk_pin: GPIO0 # SCK / D0 + +# ICS-43434 MEMS-Mikrofon +microphone: + - platform: i2s_audio + id: ics43434 + i2s_audio_id: i2s_bus + i2s_din_pin: GPIO2 # SD / D2 + adc_type: external + pdm: false + channel: left # Da L/R auf GND liegt \ No newline at end of file