Es laufen bei mir drei Komponenten zusammen. Zunächst ein Ardunio-Sketch auf einem Adafruit ESP32 Feather v2:
#define CONFIG_I2S_SUPPRESS_DEPRECATE_WARN 1
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <driver/i2s.h>
#include <driver/adc.h>
#include <time.h>
const char* WIFI_SSID = “…”;
const char* WIFI_PASSWORD = “…”;
const char* SERVER_URL = “http://192.168.0.114:8889/upload”;
const char* NTP_SERVER = “pool.ntp.org”;
// InfluxDB
#define INFLUXDB_URL “http://192.168.0.126:8086/api/v2/write?org=privat&bucket=esp32-birdnet&precision=s”
#define INFLUXDB_TOKEN “…”
// Alle wieviel Boots die Akkuspannung senden (ca. 1h im Tagestakt)
#define BATT_SEND_EVERY_N_BOOTS 6
// Aufnahme-Parameter
#define SAMPLE_RATE 48000
#define RECORD_SECONDS 15
#define SHIFT 13
#define TOTAL_SAMPLES (SAMPLE_RATE * RECORD_SECONDS)
#define TOTAL_BYTES (TOTAL_SAMPLES * 2)
#define DMA_BUF_LEN 64
// I2S Pins
#define I2S_WS 4
#define I2S_SCK 5
#define I2S_SD 19
// Intervalle in Sekunden
#define INTERVALL_MORGEN (5 * 60) // 04:00–09:00 Uhr
#define INTERVALL_TAG (10 * 60) // Rest
// RTC-Speicher — überlebt Deep Sleep
RTC_DATA_ATTR bool ntpSynced = false;
RTC_DATA_ATTR int bootCount = 0;
void writeWavHeader(uint8_t* buf, uint32_t dataBytes) {
uint32_t fileSize = dataBytes + 36;
uint32_t byteRate = SAMPLE_RATE * 2;
uint16_t blockAlign = 2;
uint32_t sr = SAMPLE_RATE;
uint32_t fmtSize = 16;
uint16_t audioFmt = 1;
uint16_t ch = 1;
uint16_t bits = 16;
memcpy(buf, “RIFF”, 4);
memcpy(buf + 4, &fileSize, 4);
memcpy(buf + 8, “WAVE”, 4);
memcpy(buf + 12, "fmt ", 4);
memcpy(buf + 16, &fmtSize, 4);
memcpy(buf + 20, &audioFmt, 2);
memcpy(buf + 22, &ch, 2);
memcpy(buf + 24, &sr, 4);
memcpy(buf + 28, &byteRate, 4);
memcpy(buf + 32, &blockAlign, 2);
memcpy(buf + 34, &bits, 2);
memcpy(buf + 36, “data”, 4);
memcpy(buf + 40, &dataBytes, 4);
}
void i2sInit() {
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 8,
.dma_buf_len = DMA_BUF_LEN,
.use_apll = false,
.tx_desc_auto_clear = false,
.fixed_mclk = 0
};
i2s_pin_config_t pins = {
.bck_io_num = I2S_SCK,
.ws_io_num = I2S_WS,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = I2S_SD
};
i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
i2s_set_pin(I2S_NUM_0, &pins);
i2s_zero_dma_buffer(I2S_NUM_0);
}
uint32_t schlafSekundenBerechnen(struct tm* jetzt) {
int stunde = jetzt->tm_hour;
if (stunde >= 4 && stunde < 9) {
return INTERVALL_MORGEN - RECORD_SECONDS;
} else {
return INTERVALL_TAG - RECORD_SECONDS;
}
}
void sendeAkkuSpannungAnInflux(float battVoltage, float rssi) {
String body = “birdnet_status,device=esp32-birdnet battery_voltage=”;
body += String(battVoltage, 3);
body += “,wifi_rssi=”;
body += String(rssi, 1);
HTTPClient http;
http.begin(INFLUXDB_URL);
http.addHeader("Authorization", "Token " + String(INFLUXDB_TOKEN));
http.addHeader("Content-Type", "text/plain; charset=utf-8");
int code = http.POST(body);
Serial.printf("InfluxDB HTTP: %d\n", code);
http.end();
}
void setup() {
Serial.begin(115200);
bootCount++;
Serial.printf(“Boot #%d\n”, bootCount);
// Akkuspannung VOR WiFi.begin() lesen — Legacy ADC1 API (kompatibel mit Legacy I2S)
adc1_config_width(ADC_WIDTH_BIT_12);
adc1_config_channel_atten(ADC1_CHANNEL_7, ADC_ATTEN_DB_11); // GPIO35 = ADC1_CHANNEL_7
int raw = adc1_get_raw(ADC1_CHANNEL_7);
float battVoltage = (raw / 4095.0f) * 3.3f * 2.0f;
Serial.printf("Akkuspannung: %.2f V (raw: %d)\n", battVoltage, raw);
// WiFi verbinden
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("WiFi verbinden");
int versuche = 0;
while (WiFi.status() != WL_CONNECTED && versuche < 20) {
delay(500); Serial.print(".");
versuche++;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("\nWiFi fehlgeschlagen — schlafe 60s");
esp_sleep_enable_timer_wakeup(60ULL * 1000000ULL);
esp_deep_sleep_start();
}
Serial.println("\nVerbunden: " + WiFi.localIP().toString());
// NTP beim ersten Boot oder alle 10 Boots neu synchronisieren
if (!ntpSynced || bootCount % 10 == 0) {
configTime(0, 0, NTP_SERVER);
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
Serial.print("NTP sync");
struct tm t;
int i = 0;
while (!getLocalTime(&t) && i < 10) {
delay(500); Serial.print("."); i++;
}
if (getLocalTime(&t)) {
ntpSynced = true;
Serial.printf("\nZeit: %02d:%02d:%02d\n", t.tm_hour, t.tm_min, t.tm_sec);
} else {
Serial.println("\nNTP fehlgeschlagen");
}
}
// Akkuspannung alle N Boots an InfluxDB senden
if (bootCount % BATT_SEND_EVERY_N_BOOTS == 0) {
Serial.println("Sende Akkuspannung an InfluxDB...");
sendeAkkuSpannungAnInflux(battVoltage, (float)WiFi.RSSI());
}
// Aktuelle Zeit holen
struct tm jetzt;
getLocalTime(&jetzt);
Serial.printf("Uhrzeit: %02d:%02d — ", jetzt.tm_hour, jetzt.tm_min);
// I2S starten
i2sInit();
delay(300);
// Audio-Buffer
uint32_t totalBytes = TOTAL_BYTES + 44;
uint8_t* audioBuf = (uint8_t*)malloc(totalBytes);
if (!audioBuf) {
Serial.println("FEHLER: Kein RAM!");
goto sleep;
}
writeWavHeader(audioBuf, TOTAL_BYTES);
{
// DC-Offset messen
int64_t dcSum = 0;
for (int i = 0; i < 500; i++) {
int32_t stereo[2] = {0, 0};
size_t b = 0;
i2s_read(I2S_NUM_0, stereo, 8, &b, portMAX_DELAY);
dcSum += (stereo[1] >> SHIFT);
}
int16_t dcOffset = (int16_t)(dcSum / 500);
Serial.printf("DC-Offset: %d\n", dcOffset);
// Aufnahme
Serial.printf("Aufnahme %d Sekunden...\n", RECORD_SECONDS);
int16_t* dst = (int16_t*)(audioBuf + 44);
uint32_t samplesRecorded = 0;
while (samplesRecorded < TOTAL_SAMPLES) {
int32_t stereo[2] = {0, 0};
size_t bytesRead = 0;
i2s_read(I2S_NUM_0, stereo, 8, &bytesRead, portMAX_DELAY);
if (bytesRead == 8) {
int16_t s = (int16_t)(stereo[1] >> SHIFT) - dcOffset;
dst[samplesRecorded++] = s;
}
}
Serial.println("Aufnahme fertig.");
// Audio-Upload
Serial.println("Sende Audio...");
HTTPClient http;
http.begin(SERVER_URL);
http.addHeader("Content-Type", "audio/wav");
int code = http.POST(audioBuf, totalBytes);
Serial.printf("HTTP Audio: %d\n", code);
http.end();
}
free(audioBuf);
sleep:
i2s_driver_uninstall(I2S_NUM_0);
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
// Schlafzeit berechnen
getLocalTime(&jetzt);
uint32_t schlafSek = schlafSekundenBerechnen(&jetzt);
Serial.printf("Schlafe %d Sekunden (Intervall %s)\n",
schlafSek,
(jetzt.tm_hour >= 4 && jetzt.tm_hour < 9) ? "5 min" : "10 min");
esp_sleep_enable_timer_wakeup((uint64_t)schlafSek * 1000000ULL);
esp_deep_sleep_start();
}
void loop() {}
Dabei ist die IP 192.168.0.114 der Raspi, auf dem Birdnet-Go läuft. Auf dem 192.168.0.126 laufen InfluxDB und Grafana. Es sind beides allerdings Raspi 5 mit NVMe-SSD und ohne SD-Karte.
Zum Verständnis: Das Bucket bei InfluxDB heißt bei mir “esp32-birdnet” und die Organisation “privat”. Ich greife hier auch noch alle 6 Übertragungen einmal die Akkuspannung und die WiFi-Stärke ab un sende sie zu InfluxDB/Grafana.
Auf dem 192.168.0.114 laufen zwei Python-Skripte:
“wav_stream.py” lauscht auf neue Dateien i den vordefinierten Verzeichnis “/mnt/daten/birdnet/input":
#!/usr/bin/env python3
import os, time, glob, subprocess, threading, queue, datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
HTTPServer = ThreadingHTTPServer
HTTPServer.allow_reuse_address = True
INPUT_DIR = “/mnt/daten/birdnet/input”
PORT = 8889
MAX_ALTER_TAGE = 2
SAMPLE_RATE = 48000
wav_queue = queue.Queue()
class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != “/stream”:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Transfer-Encoding", "identity")
self.end_headers()
print("BirdNET-Go verbunden.")
while True:
try:
wav = wav_queue.get(timeout=300)
print(f"Streame: {os.path.basename(wav)}")
proc = subprocess.Popen(
["ffmpeg", "-i", wav,
"-ar", str(SAMPLE_RATE),
"-ac", "1",
"-f", "wav",
"pipe:1"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
try:
self.wfile.write(chunk)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
print("BirdNET-Go getrennt.")
proc.kill()
return
proc.wait()
print(f"Fertig: {os.path.basename(wav)}")
except queue.Empty:
# Stille senden damit Verbindung offen bleibt
try:
self.wfile.write(b"\x00" * (SAMPLE_RATE * 2 * 60))
self.wfile.flush()
except:
return
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
data = self.rfile.read(length)
if len(data) > 100:
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(INPUT_DIR, f"audio_{ts}.wav")
with open(filename, "wb") as f:
f.write(data)
print(f"Empfangen: audio_{ts}.wav ({len(data)} Bytes)")
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"OK")
self.wfile.flush()
def log_message(self, format, *args):
pass
def ordner_beobachten():
bekannt = set()
while True:
dateien = sorted(glob.glob(f"{INPUT_DIR}/*.wav"))
for d in dateien:
if d not in bekannt:
bekannt.add(d)
wav_queue.put(d)
print(f"Neue Datei: {os.path.basename(d)}")
# Alte Dateien löschen
jetzt = time.time()
for d in list(bekannt):
if os.path.exists(d):
alter = jetzt - os.path.getmtime(d)
if alter > MAX_ALTER_TAGE * 86400:
os.remove(d)
bekannt.discard(d)
print(f"Gelöscht: {os.path.basename(d)}")
time.sleep(5)
if name == “main”:
t = threading.Thread(target=ordner_beobachten, daemon=True)
t.start()
print(f"Stream läuft: http://192.168.0.126:{PORT}/stream")
server = HTTPServer((“0.0.0.0”, PORT), StreamHandler)
server.serve_forever()
Das gaukelt Birdnet-Go einen kontinuierlichen HTTP-Stream vor. Für Birdnet-Go reicht das schon.
Da ich noch mit weitergehenden Visualisierungen spiele, läuft noch alle 10 Minuten ein weiteres Python-Skript (eingetragen in der crontab). Hierfür habe ich in InfluxDB ein weiteres Bucket “birdnet” eingerichtet.
Wenn daran noch Interesse besteht, einfach nachfragen.
Ich will mich aber nicht mit fremden Federn schmücken, die Codes stammen in weiten Teilen von “Claude”.