#include #include // --- WLAN-Konfiguration --- const char* ssid = "Stockfeld"; const char* password = "70691554384740972413"; // Hier dein echtes Passwort eintragen // --- 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 // 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 WiFiServer rtspServer(8554); WiFiClient rtspClient; i2s_chan_handle_t rx_chan = NULL; uint16_t rtpSeqNum = 0; uint32_t rtpTimestamp = 0; const uint32_t rtpSSRC = 0x12345678; bool isStreaming = false; 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_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, .bclk = PIN_I2S_BCLK, .ws = PIN_I2S_WS, .dout = I2S_GPIO_UNUSED, .din = PIN_I2S_DIN, .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)); } 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 handleRTSP() { if (rtspServer.hasClient()) { WiFiClient newClient = rtspServer.accept(); if (newClient) { if (rtspClient) rtspClient.stop(); rtspClient = newClient; rtspClient.setTimeout(50); // Timeout auf 50 ms senken, um Blockaden zu verhindern isStreaming = false; } } 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"); 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"); } 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); } } } void streamAudio() { if (!isStreaming || !rtspClient || !rtspClient.connected()) return; static int32_t i2s_raw[SAMPLES_PER_PKT]; size_t bytes_read = 0; // 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)); } } void setup() { Serial.begin(115200); 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"); } void loop() { handleRTSP(); streamAudio(); }