#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include <driver/i2s.h>
#include <arduinoFFT.h>
#include <math.h>
// ============================================================
// Audio_Monitor_R3
// ESP32 Audio Status Monitor + INMP441 6-Band FFT
//
// [Integrated Functions]
// - WiFi Web Dashboard
// - Roon ROCK monitoring
// - DHT11 temperature / humidity
// - INMP441 6-band FFT spectrum monitor
//
// [Design policy]
// - Shared status via gState
// - Web reads only gState
// - FFT runs periodically and publishes averaged result
// - Calibration offset per band
// ============================================================
// ============================================================
// 0. USER SETTINGS
// ============================================================
// ---------- WiFi ----------
const char* ssid = "<私のSSID>";
const char* password = "<私のパスワード>";
// ---------- Roon ROCK API ----------
const char* ROON_STATE_URL = "http://<私のROCK IPアドレス>/1/getstate";
// ---------- DHT11 ----------
#define DHTPIN 13
#define DHTTYPE DHT11
DHT dht(DHTPIN, DHTTYPE);
// ---------- Temperature thresholds ----------
const float TEMP_WARNING = 25.0;
const float TEMP_ERROR = 35.0;
// ---------- Web refresh ----------
const uint32_t PAGE_REFRESH_SEC = 30;
// ---------- Update intervals ----------
const uint32_t ROON_UPDATE_MS = 10000;
const uint32_t ENV_UPDATE_MS = 5000;
const uint32_t FFT_BLOCK_MS = 250; // one FFT cycle trigger
const uint32_t SPECTRUM_UPDATE_MS = 2000; // publish to dashboard every 2 sec
// ============================================================
// 1. FFT / INMP441 SETTINGS
// ============================================================
// ---------- I2S pin assignment ----------
static const int I2S_BCLK_PIN = 14; // INMP441 SCK / BCLK
static const int I2S_WS_PIN = 15; // INMP441 WS / LRCLK
static const int I2S_SD_PIN = 32; // INMP441 SD
// ---------- audio settings ----------
static const uint16_t FFT_SIZE = 1024;
static const double SAMPLE_RATE = 16000.0;
static const double BIN_WIDTH = SAMPLE_RATE / FFT_SIZE; // 15.625 Hz/bin
// ---------- low-frequency handling ----------
static const bool EXCLUDE_BIN_1 = true; // ignore 15.625 Hz bin
static const bool INCLUDE_BIN2_IN_63HZ = false; // include 31.25 Hz in 63Hz band?
// ---------- spectrum averaging ----------
static const uint8_t SPECTRUM_AVG_BLOCKS = 8; // 250ms x 8 = approx 2 sec
// ---------- display meter range ----------
static const float METER_MIN_DB = 10.0;
static const float METER_MAX_DB = 80.0;
// ---------- calibration offsets ----------
float calibrationOffsetDb[6] = {
-84.0, // 63Hz
-84.0, // 160Hz
-84.0, // 380Hz
-84.0, // 1kHz
-84.0, // 2.5kHz
-84.0 // 6.3kHz
};
// ---------- band names ----------
static const char* BAND_NAMES[6] = {
" 63Hz", " 160Hz", " 380Hz", " 1kHz", " 2.5kHz", " 6.3kHz"
};
// ---------- band definition ----------
struct BandDef {
const char* name;
double fLow;
double fHigh;
};
static const BandDef bands[6] = {
{"63Hz", 31.25, 93.75},
{"160Hz", 93.75, 250.00},
{"380Hz", 250.00, 625.00},
{"1kHz", 625.00, 1625.00},
{"2.5kHz",1625.00,4000.00},
{"6.3kHz",4000.00,8000.00}
};
// ---------- I2S port ----------
static const i2s_port_t I2S_PORT = I2S_NUM_0;
// ---------- raw I2S buffer ----------
int32_t rawSamples[FFT_SIZE];
// ---------- FFT buffers ----------
double vReal[FFT_SIZE];
double vImag[FFT_SIZE];
ArduinoFFT<double> FFT = ArduinoFFT<double>(vReal, vImag, FFT_SIZE, SAMPLE_RATE);
// ============================================================
// 2. SYSTEM STATE
// ============================================================
struct SpectrumState {
float bandDb[6]; // calibrated display dB
float rawBandDb[6]; // raw relative dB before calibration
float peakFreq;
float peakDb;
unsigned long lastUpdateMs;
bool valid;
};
struct EnvironmentState {
float temperature;
float humidity;
String tempStatus;
};
struct RoonState {
String roonStatus;
String roonVersion;
String roonUptimeText;
String rockVersion;
String rockUptime;
String storageFree;
String rockIP;
};
struct SystemState {
RoonState roon;
EnvironmentState env;
SpectrumState spectrum;
};
SystemState gState;
// ============================================================
// 3. GLOBALS
// ============================================================
WebServer server(80);
unsigned long lastRoonUpdate = 0;
unsigned long lastEnvUpdate = 0;
unsigned long lastFftKick = 0;
// averaging accumulators
float spectrumAccum[6] = {0, 0, 0, 0, 0, 0};
uint8_t spectrumAccumCount = 0;
float peakFreqAccum = 0.0;
float peakDbAccum = 0.0;
// ============================================================
// 4. HELPER FUNCTIONS
// ============================================================
// ------------------------------------------------------------
// uptime sec -> "Xd Yh"
// ------------------------------------------------------------
String formatUptime(unsigned long sec)
{
int days = sec / 86400;
int hours = (sec % 86400) / 3600;
return String(days) + "d " + String(hours) + "h";
}
// ------------------------------------------------------------
// dB -> bar percent
// ------------------------------------------------------------
int dbToPercent(float db)
{
if (db < METER_MIN_DB) db = METER_MIN_DB;
if (db > METER_MAX_DB) db = METER_MAX_DB;
return (int)(100.0f * (db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB));
}
// ------------------------------------------------------------
// power -> dB
// ------------------------------------------------------------
double powerToDb(double p)
{
const double eps = 1e-20;
return 10.0 * log10(p + eps);
}
// ------------------------------------------------------------
// bin -> frequency
// ------------------------------------------------------------
double binToFreq(uint16_t bin)
{
return (double)bin * BIN_WIDTH;
}
// ------------------------------------------------------------
// inclusive lower / upper bin helpers
// ------------------------------------------------------------
uint16_t freqToBinFloor(double freq)
{
int b = (int)floor(freq / BIN_WIDTH + 1e-9);
if (b < 0) b = 0;
if (b > (FFT_SIZE / 2 - 1)) b = FFT_SIZE / 2 - 1;
return (uint16_t)b;
}
uint16_t freqToBinCeil(double freq)
{
int b = (int)ceil(freq / BIN_WIDTH - 1e-9);
if (b < 0) b = 0;
if (b > (FFT_SIZE / 2 - 1)) b = FFT_SIZE / 2 - 1;
return (uint16_t)b;
}
// ============================================================
// 5. ROON / ENVIRONMENT
// ============================================================
// ------------------------------------------------------------
// Roon status update
// ------------------------------------------------------------
void updateRoonStatus()
{
HTTPClient http;
http.begin(ROON_STATE_URL);
http.addHeader("Content-Type", "application/x-www-form-urlencoded");
int httpCode = http.POST("");
if (httpCode > 0)
{
String payload = http.getString();
DynamicJsonDocument doc(8192);
DeserializationError error = deserializeJson(doc, payload);
if (!error)
{
bool running = doc["data"]["state"]["roon_running"];
gState.roon.roonStatus =
running ? "ONLINE" : "OFFLINE";
gState.roon.roonVersion =
doc["data"]["state"]["roon_version"].as<String>();
gState.roon.rockVersion =
doc["data"]["device_display_version"].as<String>();
gState.roon.rockIP =
doc["data"]["interfaces"][0]["state"]["ip"].as<String>();
unsigned long rockSec =
String(doc["data"]["state"]["uptime"]).toInt();
unsigned long roonSec =
String(doc["data"]["state"]["roon_uptime"]).toInt();
gState.roon.rockUptime = formatUptime(rockSec);
gState.roon.roonUptimeText = formatUptime(roonSec);
uint64_t used =
doc["data"]["state"]["internal_storage"]["data_used"];
uint64_t total =
doc["data"]["state"]["internal_storage"]["data_total"];
float freeGB =
(float)(total - used) / 1024.0 / 1024.0;
float totalGB =
(float)total / 1024.0 / 1024.0;
int percent = 0;
if (total > 0) {
percent = ((total - used) * 100) / total;
}
gState.roon.storageFree =
String((int)freeGB)
+ " GB / "
+ String((int)totalGB)
+ " GB ("
+ String(percent)
+ "% available)";
}
else
{
gState.roon.roonStatus = "JSON ERROR";
}
}
else
{
gState.roon.roonStatus = "NO RESPONSE";
}
http.end();
}
// ------------------------------------------------------------
// DHT11 update
// ------------------------------------------------------------
void updateEnvironment()
{
float h = dht.readHumidity();
float t = dht.readTemperature();
if (!isnan(h) && !isnan(t))
{
gState.env.humidity = h;
gState.env.temperature = t;
}
if (gState.env.temperature >= TEMP_ERROR)
{
gState.env.tempStatus = "ERROR";
}
else if (gState.env.temperature >= TEMP_WARNING)
{
gState.env.tempStatus = "WARNING";
}
else
{
gState.env.tempStatus = "OK";
}
}
// ============================================================
// 6. INMP441 / FFT
// ============================================================
// ------------------------------------------------------------
// I2S init
// ------------------------------------------------------------
void initI2SMic()
{
const i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = (uint32_t)SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 8,
.dma_buf_len = 256,
.use_apll = false,
.tx_desc_auto_clear = false,
.fixed_mclk = 0
};
const i2s_pin_config_t pin_config = {
.bck_io_num = I2S_BCLK_PIN,
.ws_io_num = I2S_WS_PIN,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = I2S_SD_PIN
};
i2s_driver_uninstall(I2S_PORT);
esp_err_t err;
err = i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL);
if (err != ESP_OK) {
Serial.printf("ERROR: i2s_driver_install failed (%d)\n", err);
while (1) delay(1000);
}
err = i2s_set_pin(I2S_PORT, &pin_config);
if (err != ESP_OK) {
Serial.printf("ERROR: i2s_set_pin failed (%d)\n", err);
while (1) delay(1000);
}
i2s_zero_dma_buffer(I2S_PORT);
Serial.println("I2S microphone initialized.");
}
// ------------------------------------------------------------
// read one mic block
// ------------------------------------------------------------
bool readMicBlock(int32_t* dst, size_t count)
{
size_t bytesRead = 0;
esp_err_t err = i2s_read(
I2S_PORT,
(void*)dst,
count * sizeof(int32_t),
&bytesRead,
portMAX_DELAY
);
if (err != ESP_OK) {
Serial.printf("ERROR: i2s_read failed (%d)\n", err);
return false;
}
size_t samplesRead = bytesRead / sizeof(int32_t);
return (samplesRead == count);
}
// ------------------------------------------------------------
// Prepare FFT input
// 1) convert raw sample
// 2) mean
// 3) DC remove
// 4) Hann window
// ------------------------------------------------------------
double prepareFFTInput(const int32_t* src, uint16_t n)
{
double mean = 0.0;
// pass 1 : mean
for (uint16_t i = 0; i < n; i++) {
double s = (double)(src[i] >> 8);
mean += s;
}
mean /= (double)n;
// pass 2 : DC remove + Hann
for (uint16_t i = 0; i < n; i++) {
double s = (double)(src[i] >> 8);
s -= mean;
double w = 0.5 * (1.0 - cos((2.0 * PI * i) / (n - 1)));
vReal[i] = s * w;
vImag[i] = 0.0;
}
return mean;
}
// ------------------------------------------------------------
// Find peak bin
// ------------------------------------------------------------
void findPeakBin(uint16_t& peakBin, double& peakMag)
{
uint16_t startBin = EXCLUDE_BIN_1 ? 2 : 1;
peakBin = startBin;
peakMag = vReal[startBin];
for (uint16_t i = startBin + 1; i < FFT_SIZE / 2; i++) {
if (vReal[i] > peakMag) {
peakMag = vReal[i];
peakBin = i;
}
}
}
// ------------------------------------------------------------
// Integrate one band by power sum
// ------------------------------------------------------------
double integrateBandPower(double fLow, double fHigh, bool is63Band)
{
uint16_t binStart = freqToBinCeil(fLow);
uint16_t binEnd = freqToBinFloor(fHigh);
if (binEnd < binStart) return 0.0;
double sumPower = 0.0;
for (uint16_t bin = binStart; bin <= binEnd; bin++) {
if (bin == 0) continue;
if (EXCLUDE_BIN_1 && bin == 1) continue;
if (is63Band && !INCLUDE_BIN2_IN_63HZ && bin == 2) continue;
double mag = vReal[bin];
sumPower += mag * mag;
}
return sumPower;
}
// ------------------------------------------------------------
// Publish averaged spectrum to gState
// ------------------------------------------------------------
void publishSpectrumAverage()
{
if (spectrumAccumCount == 0) return;
for (int i = 0; i < 6; i++) {
float avgRaw = spectrumAccum[i] / spectrumAccumCount;
gState.spectrum.rawBandDb[i] = avgRaw;
gState.spectrum.bandDb[i] = avgRaw + calibrationOffsetDb[i];
spectrumAccum[i] = 0.0;
}
gState.spectrum.peakFreq = peakFreqAccum / spectrumAccumCount;
gState.spectrum.peakDb = peakDbAccum / spectrumAccumCount;
peakFreqAccum = 0.0;
peakDbAccum = 0.0;
spectrumAccumCount = 0;
gState.spectrum.lastUpdateMs = millis();
gState.spectrum.valid = true;
}
// ------------------------------------------------------------
// One FFT update cycle
// ------------------------------------------------------------
void updateSpectrum()
{
// 1) read one block
if (!readMicBlock(rawSamples, FFT_SIZE)) {
return;
}
// 2) prepare input
prepareFFTInput(rawSamples, FFT_SIZE);
// 3) FFT
FFT.compute(FFT_FORWARD);
FFT.complexToMagnitude();
// 4) peak
uint16_t peakBin;
double peakMag;
findPeakBin(peakBin, peakMag);
float peakFreq = (float)binToFreq(peakBin);
float peakDb = (float)(20.0 * log10(peakMag + 1e-12));
// 5) 6-band integration
float bandRawDb[6];
for (uint8_t i = 0; i < 6; i++) {
bool is63Band = (i == 0);
double p = integrateBandPower(bands[i].fLow, bands[i].fHigh, is63Band);
bandRawDb[i] = (float)powerToDb(p);
}
// 6) accumulate
for (int i = 0; i < 6; i++) {
spectrumAccum[i] += bandRawDb[i];
}
peakFreqAccum += peakFreq;
peakDbAccum += peakDb;
spectrumAccumCount++;
// 7) publish every N blocks
if (spectrumAccumCount >= SPECTRUM_AVG_BLOCKS) {
publishSpectrumAverage();
}
}
// ============================================================
// 7. HTML HELPERS
// ============================================================
// ------------------------------------------------------------
// Return health label for Roon system
// ------------------------------------------------------------
String buildSystemHealthHtml()
{
if (gState.roon.roonStatus == "ONLINE") {
return "<span class='ok'>OK</span>";
}
return "<span class='ng'>ERROR</span>";
}
// ------------------------------------------------------------
// Return temp status label
// ------------------------------------------------------------
String buildTempStatusHtml()
{
if (gState.env.tempStatus == "OK") {
return "<span class='ok'>OK</span>";
}
else if (gState.env.tempStatus == "WARNING") {
return "<span class='warning'>WARNING</span>";
}
else {
return "<span class='ng'>NG</span>";
}
}
// ------------------------------------------------------------
// Build one spectrum row
// ------------------------------------------------------------
String buildSpectrumRow(const char* label, float db)
{
String html;
int width = dbToPercent(db);
html += "<div class='spec-row'>";
html += "<div class='spec-label'>";
html += label;
html += "</div>";
html += "<div class='spec-bar-wrap'>";
html += "<div class='spec-bar' style='width:";
html += String(width);
html += "%;'></div>";
html += "</div>";
html += "<div class='spec-value'>";
html += String(db, 1);
html += " dB</div>";
html += "</div>";
return html;
}
// ============================================================
// 8. WEB PAGE
// ============================================================
void handleRoot()
{
String html;
html += "<html>";
html += "<head>";
html += "<title>Audio System Monitoring</title>";
html += "<meta http-equiv='refresh' content='";
html += String(PAGE_REFRESH_SEC);
html += "'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<style>";
html += "body{font-family:Arial,Helvetica,sans-serif;margin:20px;background:#ffffff;color:#222;}";
html += "h1{margin-bottom:8px;}";
html += "h2{margin-top:28px;margin-bottom:10px;}";
html += "table{width:100%;border-collapse:collapse;margin-bottom:10px;}";
html += "td{padding:8px;border-bottom:1px solid #cccccc;vertical-align:top;}";
html += ".ok{color:green;font-weight:bold;}";
html += ".ng{color:red;font-weight:bold;}";
html += ".warning{color:orange;font-weight:bold;}";
html += ".spec-row{display:flex;align-items:center;margin:10px 0;}";
html += ".spec-label{width:130px;font-weight:bold;}";
html += ".spec-bar-wrap{flex:1;height:20px;background:#e0e0e0;border-radius:4px;overflow:hidden;margin-right:32px;}";
html += ".spec-bar{height:100%;background:#4caf50;}";
html += ".spec-value{width:90px;text-align:right;font-family:monospace;}";
html += ".note{color:#666;font-size:12px;}";
html += ".section-box{border:1px solid #dddddd;border-radius:8px;padding:16px;margin-top:16px;}";
html += "</style>";
html += "</head>";
html += "<body>";
// --------------------------------------------------------
// title
// --------------------------------------------------------
html += "<h1>Audio System Monitoring</h1>";
html += "<hr>";
// Optional image
html += "<img src='https://keroyon-audio.com/wp-content/uploads/2026/06/audio_dashboard.jpg' width='100%'>";
// --------------------------------------------------------
// system health
// --------------------------------------------------------
html += "<h2>System Health : ";
html += buildSystemHealthHtml();
html += "</h2>";
html += "<p>Audit since update : ";
html += String(millis() / 60000);
html += " min.</p>";
// --------------------------------------------------------
// Roon ROCK section
// --------------------------------------------------------
html += "<div class='section-box'>";
html += "<h2>Roon ROCK</h2>";
html += "<table>";
html += "<tr>";
html += "<td>Roon OS</td>";
html += "<td class='ok'>OK</td>";
html += "<td>Version : ";
html += gState.roon.rockVersion;
html += " Running : ";
html += gState.roon.rockUptime;
html += "</td>";
html += "</tr>";
html += "<tr>";
html += "<td>Roon Server</td>";
if (gState.roon.roonStatus == "ONLINE") {
html += "<td class='ok'>OK</td>";
} else {
html += "<td class='ng'>NG</td>";
}
html += "<td>Version : ";
html += gState.roon.roonVersion;
html += " Running : ";
html += gState.roon.roonUptimeText;
html += "</td>";
html += "</tr>";
html += "<tr>";
html += "<td>Music Storage</td>";
html += "<td class='ok'>OK</td>";
html += "<td>";
html += gState.roon.storageFree;
html += "</td>";
html += "</tr>";
html += "<tr>";
html += "<td>ROCK IP Address</td>";
html += "<td></td>";
html += "<td>";
html += gState.roon.rockIP;
html += "</td>";
html += "</tr>";
html += "<tr>";
html += "<td>ROCK-side Temp.</td>";
html += "<td>";
html += buildTempStatusHtml();
html += "</td>";
html += "<td>";
html += String(gState.env.temperature, 1);
html += " degrees";
html += "</td>";
html += "</tr>";
html += "<tr>";
html += "<td>ROCK-side Humidity</td>";
html += "<td class='ok'>OK</td>";
html += "<td>";
html += String(gState.env.humidity, 1);
html += " %";
html += "</td>";
html += "</tr>";
html += "<tr>";
html += "<td>Playback Now</td>";
html += "<td></td>";
html += "<td>Not Implemented</td>";
html += "</tr>";
html += "</table>";
html += "</div>";
// --------------------------------------------------------
// Audio Spectrum section
// --------------------------------------------------------
html += "<div class='section-box'>";
html += "<h2>Fan Nose Spectrum</h2>";
if (!gState.spectrum.valid)
{
html += "<p>Initializing spectrum monitor...</p>";
}
else
{
for (int i = 0; i < 6; i++) {
html += buildSpectrumRow(BAND_NAMES[i], gState.spectrum.bandDb[i]);
}
html += "<p class='note'>";
html += "Peak : ";
html += String(gState.spectrum.peakFreq, 1);
html += " Hz / ";
html += String(gState.spectrum.peakDb, 1);
html += " dB raw";
html += "</p>";
html += "<p class='note'>";
html += "Spectrum update : every ";
html += String(SPECTRUM_UPDATE_MS / 1000.0, 1);
html += " sec, averaged over ";
html += String(SPECTRUM_AVG_BLOCKS);
html += " FFT blocks";
html += "</p>";
}
html += "</div>";
// --------------------------------------------------------
// footer
// --------------------------------------------------------
html += "<hr>";
html += "<small>";
html += "ESP32 : ";
html += WiFi.localIP().toString();
html += " | RSSI ";
html += String(WiFi.RSSI());
html += " dBm";
html += " | Heap ";
html += String(ESP.getFreeHeap());
html += " byte";
html += "</small>";
html += "</body>";
html += "</html>";
server.send(200, "text/html", html);
}
// ============================================================
// 9. SETUP
// ============================================================
void setup()
{
Serial.begin(115200);
delay(2000);
Serial.println();
Serial.println("============================================================");
Serial.println("Audio_Monitor_R3 : boot start");
Serial.println("============================================================");
// ---------- initialize state ----------
gState.roon.roonStatus = "UNKNOWN";
gState.roon.roonVersion = "-";
gState.roon.roonUptimeText = "-";
gState.roon.rockVersion = "-";
gState.roon.rockUptime = "-";
gState.roon.storageFree = "-";
gState.roon.rockIP = "-";
gState.env.temperature = 0.0;
gState.env.humidity = 0.0;
gState.env.tempStatus = "OK";
gState.spectrum.peakFreq = 0.0;
gState.spectrum.peakDb = 0.0;
gState.spectrum.lastUpdateMs = 0;
gState.spectrum.valid = false;
for (int i = 0; i < 6; i++) {
gState.spectrum.bandDb[i] = 0.0;
gState.spectrum.rawBandDb[i] = 0.0;
}
// ---------- DHT ----------
dht.begin();
Serial.println("DHT11 READY");
// ---------- WiFi ----------
WiFi.mode(WIFI_STA);
Serial.println("CONNECTING TO WIFI...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("WiFi CONNECTED");
Serial.print("IP ADDRESS: ");
Serial.println(WiFi.localIP());
// ---------- I2S Mic ----------
initI2SMic();
// ---------- initial update ----------
updateEnvironment();
updateRoonStatus();
// ---------- Web server ----------
server.on("/", handleRoot);
server.begin();
Serial.println("WEB SERVER STARTED");
Serial.println("BOOT COMPLETE");
}
// ============================================================
// 10. LOOP
// ============================================================
void loop()
{
server.handleClient();
unsigned long now = millis();
// ---------- Roon ----------
if (now - lastRoonUpdate >= ROON_UPDATE_MS)
{
lastRoonUpdate = now;
updateRoonStatus();
Serial.println("[ROON] status updated");
Serial.println(gState.roon.roonStatus);
Serial.println(gState.roon.roonVersion);
}
// ---------- Environment ----------
if (now - lastEnvUpdate >= ENV_UPDATE_MS)
{
lastEnvUpdate = now;
updateEnvironment();
Serial.print("[ENV] Temp = ");
Serial.print(gState.env.temperature, 1);
Serial.print(" C, Humidity = ");
Serial.print(gState.env.humidity, 1);
Serial.println(" %");
}
// ---------- FFT ----------
if (now - lastFftKick >= FFT_BLOCK_MS)
{
lastFftKick = now;
updateSpectrum();
if (gState.spectrum.valid) {
Serial.print("[FFT] ");
for (int i = 0; i < 6; i++) {
Serial.print(BAND_NAMES[i]);
Serial.print(":");
Serial.print(gState.spectrum.bandDb[i], 1);
if (i < 5) Serial.print(" / ");
}
Serial.println();
}
}
}