diff --git a/components/custom_board/CMakeLists.txt b/components/custom_board/CMakeLists.txt index 2139c097..12925b4a 100644 --- a/components/custom_board/CMakeLists.txt +++ b/components/custom_board/CMakeLists.txt @@ -2,6 +2,11 @@ set(COMPONENT_REQUIRES audio_hal audio_board) set(COMPONENT_PRIV_REQUIRES esp_peripherals) +# Add tas5805m_settings dependency when TAS5805M DAC is enabled +if(CONFIG_DAC_TAS5805M) + list(APPEND COMPONENT_PRIV_REQUIRES tas5805m_settings) +endif() + if(CONFIG_AUDIO_BOARD_CUSTOM) message(STATUS "Current board name is " CONFIG_AUDIO_BOARD_CUSTOM) set(COMPONENT_ADD_INCLUDEDIRS ./generic_board/include) @@ -49,6 +54,7 @@ if(CONFIG_AUDIO_BOARD_CUSTOM) if(CONFIG_DAC_TAS5805M) message(STATUS "Selected DAC is " CONFIG_DAC_TAS5805M) list(APPEND COMPONENT_ADD_INCLUDEDIRS ./tas5805m/include) + list(APPEND COMPONENT_ADD_INCLUDEDIRS ../tas5805m_settings/include) list(APPEND COMPONENT_SRCS ./tas5805m/tas5805m.c) endif() diff --git a/components/custom_board/tas5805m/tas5805m.c b/components/custom_board/tas5805m/tas5805m.c index 8374ce42..b3bb8047 100644 --- a/components/custom_board/tas5805m/tas5805m.c +++ b/components/custom_board/tas5805m/tas5805m.c @@ -30,9 +30,18 @@ #include "i2c_bus.h" #include "tas5805m_reg_cfg.h" #include +#include "freertos/semphr.h" +#include "freertos/portmacro.h" + +#if CONFIG_DAC_TAS5805M +#include "tas5805m_settings.h" +#endif static const char *TAG = "TAS5805M"; +/* Mutex for thread-safe I2C access */ +static SemaphoreHandle_t tas5805m_i2c_mutex = NULL; + #define TAS5805M_SET_BOOK_AND_PAGE(BOOK, PAGE) \ do { \ tas5805m_write_byte(TAS5805M_REG_PAGE_SET, TAS5805M_REG_PAGE_ZERO); \ @@ -157,6 +166,11 @@ void i2c_master_init() { // Reading of TAS5805M-Register esp_err_t tas5805m_read_byte(uint8_t register_name, uint8_t *data) { + if (tas5805m_i2c_mutex && xSemaphoreTakeRecursive(tas5805m_i2c_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGE(TAG, "%s: Failed to acquire I2C mutex", __func__); + return ESP_ERR_TIMEOUT; + } + int ret; i2c_cmd_handle_t cmd = i2c_cmd_link_create(); i2c_master_start(cmd); @@ -179,11 +193,18 @@ esp_err_t tas5805m_read_byte(uint8_t register_name, uint8_t *data) { ret = i2c_master_cmd_begin(I2C_TAS5805M_MASTER_NUM, cmd, pdMS_TO_TICKS(1000)); i2c_cmd_link_delete(cmd); ESP_LOGV(TAG, "%s: Read 0x%02x from register 0x%02x", __func__, *data, register_name); + + if (tas5805m_i2c_mutex) xSemaphoreGiveRecursive(tas5805m_i2c_mutex); return ret; } // Writing of TAS5805M-Register esp_err_t tas5805m_write_byte(uint8_t register_name, uint8_t value) { + if (tas5805m_i2c_mutex && xSemaphoreTakeRecursive(tas5805m_i2c_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGE(TAG, "%s: Failed to acquire I2C mutex", __func__); + return ESP_ERR_TIMEOUT; + } + int ret = 0; ESP_LOGV(TAG, "%s: Writing 0x%02x to register 0x%02x", __func__, value, register_name); @@ -203,6 +224,92 @@ esp_err_t tas5805m_write_byte(uint8_t register_name, uint8_t value) { i2c_cmd_link_delete(cmd); + if (tas5805m_i2c_mutex) xSemaphoreGiveRecursive(tas5805m_i2c_mutex); + return ret; +} + +esp_err_t tas5805m_write_bytes(uint8_t *reg, + int regLen, uint8_t *data, int datalen) +{ + if (tas5805m_i2c_mutex && xSemaphoreTakeRecursive(tas5805m_i2c_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGE(TAG, "%s: Failed to acquire I2C mutex", __func__); + return ESP_ERR_TIMEOUT; + } + + int ret = ESP_OK; + ESP_LOGV(TAG, "%s: 0x%02x <- [%d] bytes", __func__, *reg, datalen); + for (int i = 0; i < datalen; i++) + { + ESP_LOGV(TAG, "%s: 0x%02x", __func__, data[i]); + } + + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + ret |= i2c_master_start(cmd); + ret |= i2c_master_write_byte(cmd, TAS5805M_ADDRESS << 1 | WRITE_BIT, ACK_CHECK_EN); + ret |= i2c_master_write(cmd, reg, regLen, ACK_CHECK_EN); + ret |= i2c_master_write(cmd, data, datalen, ACK_CHECK_EN); + ret |= i2c_master_stop(cmd); + ret = i2c_master_cmd_begin(I2C_TAS5805M_MASTER_NUM, cmd, 1000 / portTICK_RATE_MS); + + // Check if ret is OK + if (ret != ESP_OK) + { + ESP_LOGE(TAG, "%s: Error during I2C transmission: %s", __func__, esp_err_to_name(ret)); + } + + i2c_cmd_link_delete(cmd); + + if (tas5805m_i2c_mutex) xSemaphoreGiveRecursive(tas5805m_i2c_mutex); + return ret; +} + +esp_err_t tas5805m_read_bytes(uint8_t *reg, int regLen, uint8_t *data, int datalen) +{ + if (tas5805m_i2c_mutex && xSemaphoreTakeRecursive(tas5805m_i2c_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + ESP_LOGE(TAG, "%s: Failed to acquire I2C mutex", __func__); + return ESP_ERR_TIMEOUT; + } + + int ret = ESP_OK; + ESP_LOGV(TAG, "%s: 0x%02x -> [%d] bytes", __func__, *reg, datalen); + + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + ret |= i2c_master_start(cmd); + ret |= i2c_master_write_byte(cmd, TAS5805M_ADDRESS << 1 | WRITE_BIT, ACK_CHECK_EN); + ret |= i2c_master_write(cmd, reg, regLen, ACK_CHECK_EN); + ret |= i2c_master_stop(cmd); + ret = i2c_master_cmd_begin(I2C_TAS5805M_MASTER_NUM, cmd, 1000 / portTICK_RATE_MS); + i2c_cmd_link_delete(cmd); + + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Error during I2C write phase: %s", __func__, esp_err_to_name(ret)); + if (tas5805m_i2c_mutex) xSemaphoreGiveRecursive(tas5805m_i2c_mutex); + return ret; + } + + vTaskDelay(1 / portTICK_PERIOD_MS); + + cmd = i2c_cmd_link_create(); + ret |= i2c_master_start(cmd); + ret |= i2c_master_write_byte(cmd, TAS5805M_ADDRESS << 1 | READ_BIT, ACK_CHECK_EN); + if (datalen > 1) { + ret |= i2c_master_read(cmd, data, datalen - 1, ACK_VAL); + } + ret |= i2c_master_read_byte(cmd, data + datalen - 1, NACK_VAL); + ret |= i2c_master_stop(cmd); + ret = i2c_master_cmd_begin(I2C_TAS5805M_MASTER_NUM, cmd, 1000 / portTICK_RATE_MS); + + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Error during I2C read phase: %s", __func__, esp_err_to_name(ret)); + } else { + for (int i = 0; i < datalen; i++) { + ESP_LOGV(TAG, "%s: [%d] = 0x%02x", __func__, i, data[i]); + } + } + + i2c_cmd_link_delete(cmd); + + if (tas5805m_i2c_mutex) xSemaphoreGiveRecursive(tas5805m_i2c_mutex); return ret; } @@ -282,6 +389,17 @@ esp_err_t tas5805m_read_bytes(uint8_t *reg, int regLen, uint8_t *data, int datal esp_err_t tas5805m_init() { ESP_LOGD(TAG, "%s: Initializing TAS5805M", __func__); int ret = 0; + + /* Create I2C mutex if not already created (recursive to allow nested calls). + * Safe without locking: called from app_main before concurrent access. */ + if (tas5805m_i2c_mutex == NULL) { + tas5805m_i2c_mutex = xSemaphoreCreateRecursiveMutex(); + if (tas5805m_i2c_mutex == NULL) { + ESP_LOGE(TAG, "%s: Failed to create I2C mutex", __func__); + return ESP_ERR_NO_MEM; + } + } + // Init the I2C-Driver i2c_master_init(); @@ -346,7 +464,7 @@ esp_err_t tas5805m_init() { BaseType_t task_ret = xTaskCreate( tas5805m_fault_monitor_task, "tas5805m_faults", - 3 * 1024, + 4096, NULL, 5, &tas5805m_fault_monitor_task_handle @@ -419,6 +537,10 @@ esp_err_t tas5805m_set_volume(int vol) { esp_err_t ret = tas5805m_write_byte(TAS5805M_DIG_VOL_CTRL_REGISTER, reg_val); if (ret == ESP_OK) { tas5805m_state.volume = vol; +#if CONFIG_DAC_TAS5805M + /* Apply loudness compensation based on new volume level */ + tas5805m_loudness_apply(vol); +#endif } else { ESP_LOGW(TAG, "%s: Failed to write volume (reg 0x%02x): %s", __func__, reg_val, esp_err_to_name(ret)); } @@ -1176,36 +1298,42 @@ esp_err_t tas5805m_read_biquad_coefficients(tas5805m_eq_chan_t channel, int band return ESP_ERR_INVALID_ARG; } - ESP_LOGD(TAG, "%s: Reading biquad coefficients for channel %d, band %d", + ESP_LOGD(TAG, "%s: Reading biquad coefficients for channel %d, band %d", __func__, channel, band); esp_err_t ret = ESP_OK; uint8_t page, offset; uint32_t raw_value; - + // Read each coefficient float *coeffs[] = {b0, b1, b2, a1, a2}; const char *names[] = {"B0", "B1", "B2", "A1", "A2"}; - + for (int i = 0; i < TAS5805M_EQ_KOEF_PER_BAND; i++) { ret = tas5805m_get_biquad_register(channel, band, i, &page, &offset); if (ret != ESP_OK) { return ret; } - + TAS5805M_SET_BOOK_AND_PAGE(TAS5805M_REG_BOOK_EQ, page); - + ret = tas5805m_read_bytes(&offset, 1, (uint8_t *)&raw_value, sizeof(raw_value)); if (ret != ESP_OK) { - ESP_LOGE(TAG, "%s: Failed to read coefficient %s: %s", + ESP_LOGE(TAG, "%s: Failed to read coefficient %s: %s", __func__, names[i], esp_err_to_name(ret)); break; } - + *coeffs[i] = tas5805m_q5_27_to_float(raw_value); ESP_LOGD(TAG, "%s: %s = %f (raw: 0x%08X)", __func__, names[i], *coeffs[i], (unsigned int)raw_value); } - + + // TAS5805M uses addition convention for feedback: y = b0*x + b1*x1 + b2*x2 + a1*y1 + a2*y2 + // Standard DSP uses subtraction: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 + // Negate a1 and a2 to convert from TAS5805M convention back to standard DSP convention + *a1 = -(*a1); + *a2 = -(*a2); + TAS5805M_SET_BOOK_AND_PAGE(TAS5805M_REG_BOOK_CONTROL_PORT, TAS5805M_REG_PAGE_ZERO); return ret; } @@ -1224,7 +1352,10 @@ esp_err_t tas5805m_write_biquad_coefficients(tas5805m_eq_chan_t channel, int ban uint8_t page, offset; uint32_t raw_value; - float coeffs[] = {b0, b1, b2, a1, a2}; + // TAS5805M uses addition convention for feedback: y = b0*x + b1*x1 + b2*x2 + a1*y1 + a2*y2 + // Standard DSP uses subtraction: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2 + // So we negate a1 and a2 to convert from standard DSP convention to TAS5805M convention + float coeffs[] = {b0, b1, b2, -a1, -a2}; const char *names[] = {"B0", "B1", "B2", "A1", "A2"}; for (int i = 0; i < TAS5805M_EQ_KOEF_PER_BAND; i++) { @@ -1302,8 +1433,8 @@ uint32_t tas5805m_float_to_q5_27(float value) int32_t fixed_val = (int32_t)(value * (1 << 27)); uint32_t le_val = tas5805m_swap_endian_32((uint32_t)fixed_val); - // ESP_LOGD(TAG, "%s: value=%f -> fixed_val=%d, le_val=0x%08X", - // __func__, value, fixed_val, (unsigned int)le_val); + ESP_LOGD(TAG, "%s: value=%f -> fixed_val=%ld, le_val=0x%08lX", + __func__, value, (long)fixed_val, (unsigned long)le_val); return le_val; } diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index b9b5f245..acbe225d 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -582,7 +582,10 @@ int start_player() { esp_pm_lock_acquire(player_pm_lock_handle); #endif - if (pcmChkQHdl == NULL) + // create message queue to inform task of changed settings + snapcastSettingQueueHandle = xQueueCreate(1, sizeof(uint8_t)); + + if (pcmChkQHdl == NULL) { int entries = ceil(((float)scSet->sr / (float)scSet->chkInFrames) * diff --git a/components/tas5805m_settings/CMakeLists.txt b/components/tas5805m_settings/CMakeLists.txt index 99c431bd..9000de74 100644 --- a/components/tas5805m_settings/CMakeLists.txt +++ b/components/tas5805m_settings/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "tas5805m_settings.c" + SRCS "tas5805m_settings.c" "tas5805m_biamp.c" INCLUDE_DIRS "include" REQUIRES nvs_flash custom_board json ) diff --git a/components/tas5805m_settings/include/tas5805m_biamp.h b/components/tas5805m_settings/include/tas5805m_biamp.h new file mode 100644 index 00000000..d284ab2f --- /dev/null +++ b/components/tas5805m_settings/include/tas5805m_biamp.h @@ -0,0 +1,402 @@ +/** + * @file tas5805m_biamp.h + * @brief Advanced Bi-Amp Crossover DSP for TAS5805M + * + * This module provides biquad filter coefficient calculations and hardware + * programming for active crossover configurations on the TAS5805M DAC. + * + * @section biamp_overview Overview + * + * In bi-amp mode, the TAS5805M's stereo outputs are repurposed: + * - Left channel -> Lowpass filter -> Woofer amplifier + * - Right channel -> Highpass filter -> Tweeter amplifier + * + * @section biamp_filters Crossover Filter Types + * + * Butterworth: + * - -3dB at crossover frequency + * - Maximally flat passband + * - 180° phase shift at crossover (12dB/oct), 360° (24dB/oct) + * + * Linkwitz-Riley: + * - -6dB at crossover frequency + * - Flat summed amplitude response when drivers are in-phase + * - Created by cascading two Butterworth filters + * + * @section biamp_slopes Supported Slopes + * + * - 12 dB/octave: 1 biquad stage (2nd order) + * - 24 dB/octave: 2 biquad stages (4th order, standard LR) + * - 48 dB/octave: 4 biquad stages (8th order) + * + * @section biamp_bands Biquad Band Allocation + * + * Each channel has 15 biquad bands (0-14). Bi-amp mode allocates them as: + * + * LEFT (Woofer): RIGHT (Tweeter): + * - Band 0: Gain + phase - Band 0: Gain + phase + * - Band 1: Subsonic HPF - Band 1: Time alignment delay + * - Bands 2-5: Lowpass crossover - Bands 2-5: Highpass crossover + * - Bands 6-11: Parametric EQ - Bands 6-11: Parametric EQ + * - Band 12: Baffle step compensation - Band 12: Breakup notch filter + * - Band 13: Bass shelf (loudness) - Band 13: Air/brilliance shelf + * - Band 14: Spare (loudness treble) - Band 14: Treble shelf (loudness) + */ + +#ifndef __TAS5805M_BIAMP_H__ +#define __TAS5805M_BIAMP_H__ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#if CONFIG_DAC_TAS5805M + +#include "tas5805m_settings.h" +#include "tas5805m_types.h" + +/** + * @brief Biquad filter coefficient structure + * + * Standard direct form I biquad transfer function: + * H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2) + * + * Note: a0 is normalized to 1.0 and not stored. + */ +typedef struct { + float b0; /**< Feedforward coefficient 0 (input gain) */ + float b1; /**< Feedforward coefficient 1 */ + float b2; /**< Feedforward coefficient 2 */ + float a1; /**< Feedback coefficient 1 */ + float a2; /**< Feedback coefficient 2 */ +} tas5805m_biquad_coeffs_t; + +/** + * @brief Supported sample rates for coefficient calculations + */ +typedef enum { + BIAMP_SAMPLE_RATE_44100 = 44100, + BIAMP_SAMPLE_RATE_48000 = 48000, + BIAMP_SAMPLE_RATE_88200 = 88200, + BIAMP_SAMPLE_RATE_96000 = 96000, +} tas5805m_biamp_sample_rate_t; + +/** Default sample rate used when none specified */ +#define TAS5805M_BIAMP_DEFAULT_SAMPLE_RATE BIAMP_SAMPLE_RATE_48000 + +/* ============ Biquad Band Assignments ============ */ + +#define BIAMP_GAIN_BAND 0 /**< Gain and phase control */ +#define BIAMP_SUBSONIC_BAND 1 /**< Subsonic HPF (woofer channel) */ +#define BIAMP_TWEETER_DELAY_BAND 1 /**< Time alignment all-pass (tweeter channel) */ +#define BIAMP_CROSSOVER_START_BAND 2 /**< First crossover filter band */ +#define BIAMP_CROSSOVER_MAX_BANDS 4 /**< Maximum crossover stages (48dB/oct) */ +#define BIAMP_PEQ_START_BAND 6 /**< First parametric EQ band */ +#define BIAMP_PEQ_BANDS 6 /**< Number of PEQ bands per channel */ +#define BIAMP_BAFFLE_STEP_BAND 12 /**< Baffle step compensation (woofer) */ +#define BIAMP_NOTCH_BAND 12 /**< Breakup notch filter (tweeter) */ +#define BIAMP_AIR_SHELF_BAND 13 /**< Air/brilliance shelf (tweeter) */ + +/* ============ Filter Coefficient Calculators ============ */ + +/** + * @brief Calculate 2nd-order Butterworth lowpass filter coefficients + * + * Butterworth filters have maximally flat passband response. + * Uses bilinear transform with frequency pre-warping. + * + * @param fc Cutoff frequency in Hz (-3dB point) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_butterworth_lpf(float fc, float fs, tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate 2nd-order Butterworth highpass filter coefficients + * + * @param fc Cutoff frequency in Hz (-3dB point) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_butterworth_hpf(float fc, float fs, tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate parametric EQ (peaking) filter coefficients + * + * Creates a bell-shaped boost or cut centered at the specified frequency. + * Based on the Audio EQ Cookbook by Robert Bristow-Johnson. + * + * @param fc Center frequency in Hz + * @param gain_db Gain in dB (negative for cut, positive for boost) + * @param q Q factor controlling bandwidth (higher = narrower) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_peq(float fc, float gain_db, float q, float fs, tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate gain stage coefficients + * + * Creates a simple gain/attenuation with no frequency shaping. + * Implemented as b0 = linear_gain, all other coefficients zero. + * + * @param gain_db Gain in dB + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if coeffs is NULL + */ +esp_err_t tas5805m_calc_gain(float gain_db, tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate unity passthrough coefficients + * + * Creates coefficients that pass the signal unchanged (b0=1, all others=0). + * Use this to disable a biquad band without affecting the signal. + * + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if coeffs is NULL + */ +esp_err_t tas5805m_calc_passthrough(tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate phase inversion coefficients + * + * Creates coefficients that invert the signal polarity (b0=-1). + * Equivalent to 180° phase shift at all frequencies. + * + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if coeffs is NULL + */ +esp_err_t tas5805m_calc_phase_invert(tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate low shelf filter coefficients + * + * Boosts or cuts frequencies below the shelf frequency. + * Based on the Audio EQ Cookbook by Robert Bristow-Johnson. + * + * @param fc Shelf transition frequency in Hz + * @param gain_db Gain in dB for frequencies below fc + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_low_shelf(float fc, float gain_db, float fs, tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Calculate high shelf filter coefficients + * + * Boosts or cuts frequencies above the shelf frequency. + * Based on the Audio EQ Cookbook by Robert Bristow-Johnson. + * + * @param fc Shelf transition frequency in Hz + * @param gain_db Gain in dB for frequencies above fc + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_high_shelf(float fc, float gain_db, float fs, tas5805m_biquad_coeffs_t *coeffs); + +/** + * @brief Get the number of biquad stages required for a crossover slope + * + * @param slope Crossover slope setting (12/24/48 dB/octave) + * @return Number of cascaded biquad stages (1, 2, or 4) + */ +int tas5805m_biamp_get_biquad_count(tas5805m_biamp_slope_t slope); + +/** + * @brief Calculate baffle step compensation parameters + * + * Baffle step is the acoustic phenomenon where low frequencies diffract + * around the speaker cabinet while high frequencies beam forward. This + * causes a 6dB level difference between low and high frequencies. + * + * The transition frequency depends on baffle width: + * f_step = speed_of_sound / (π × baffle_width) + * + * Compensation amount depends on room placement: + * - Freestanding: +6dB bass boost (no boundary reinforcement) + * - Near wall: +3dB bass boost (partial reinforcement) + * - Corner: 0dB (full boundary reinforcement) + * + * @param baffle_width_cm Width of speaker baffle in centimeters + * @param placement Room placement affecting compensation amount + * @param step_freq_hz Output: calculated shelf frequency + * @param gain_db Output: calculated bass boost amount + */ +void tas5805m_calc_baffle_step(uint8_t baffle_width_cm, + tas5805m_baffle_placement_t placement, + float *step_freq_hz, float *gain_db); + +/** + * @brief Calculate first-order all-pass filter for time alignment + * + * All-pass filters pass all frequencies at unity gain but introduce + * frequency-dependent phase shift (group delay). This delays the tweeter + * signal to align with the woofer's acoustic center. + * + * The delay is approximate and most accurate near the crossover frequency + * where driver alignment matters most. + * + * @param delay_mm Physical distance to delay in millimeters + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_allpass_delay(uint8_t delay_mm, float fs, tas5805m_biquad_coeffs_t *coeffs); + +/* ============ Full Configuration Apply ============ */ + +/** + * @brief Apply complete bi-amp crossover configuration + * + * Programs all biquad bands on both channels with the crossover filters, + * gain stages, PEQ, and speaker compensation filters. This is the main + * entry point for configuring bi-amp mode. + * + * @param settings Complete bi-amp configuration structure + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply(const tas5805m_biamp_settings_t *settings); + +/** + * @brief Initialize bi-amp settings structure to defaults + * + * Sets sensible defaults: + * - 2000 Hz crossover, 24dB/oct Linkwitz-Riley + * - 0dB gain, no phase invert on both outputs + * - All PEQ bands disabled + * - No speaker compensation filters + * + * @param settings Structure to initialize + */ +void tas5805m_biamp_init_defaults(tas5805m_biamp_settings_t *settings); + +/* ============ Individual Band Apply Functions ============ */ + +/** + * @brief Apply woofer (low output) gain and phase + * + * Updates only band 0 on the left channel. Use this for efficient + * single-parameter updates instead of full re-apply. + * + * @param gain_x2 Gain × 2 for 0.5dB resolution (-48 to +48 = -24dB to +24dB) + * @param phase_invert 0 = normal polarity, 1 = inverted (180° phase) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_low_gain_phase(int8_t gain_x2, uint8_t phase_invert, uint32_t sample_rate); + +/** + * @brief Apply tweeter (high output) gain and phase + * + * Updates only band 0 on the right channel. + * + * @param gain_x2 Gain × 2 for 0.5dB resolution + * @param phase_invert 0 = normal polarity, 1 = inverted + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_high_gain_phase(int8_t gain_x2, uint8_t phase_invert, uint32_t sample_rate); + +/** + * @brief Apply subsonic highpass filter + * + * Protects the woofer from excessive excursion at very low frequencies. + * Updates band 1 on the left channel. + * + * @param freq Cutoff frequency in Hz (0 = disabled/passthrough) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_subsonic(uint16_t freq, uint32_t sample_rate); + +/** + * @brief Apply tweeter time alignment delay + * + * Uses an all-pass filter to delay the tweeter and align it with the + * woofer's acoustic center. Updates band 1 on the right channel. + * + * @param delay_mm Delay distance in millimeters (0 = disabled) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_tweeter_delay(uint8_t delay_mm, uint32_t sample_rate); + +/** + * @brief Apply a single parametric EQ band for the woofer + * + * Updates one of the 6 PEQ bands (6-11) on the left channel. + * + * @param band_index PEQ band index (0-5, maps to hardware bands 6-11) + * @param peq PEQ band settings (freq, gain, Q) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if band_index out of range + */ +esp_err_t tas5805m_biamp_apply_low_peq(int band_index, const tas5805m_biamp_peq_band_t *peq, uint32_t sample_rate); + +/** + * @brief Apply a single parametric EQ band for the tweeter + * + * Updates one of the 6 PEQ bands (6-11) on the right channel. + * + * @param band_index PEQ band index (0-5, maps to hardware bands 6-11) + * @param peq PEQ band settings (freq, gain, Q) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if band_index out of range + */ +esp_err_t tas5805m_biamp_apply_high_peq(int band_index, const tas5805m_biamp_peq_band_t *peq, uint32_t sample_rate); + +/** + * @brief Apply baffle step compensation + * + * Low shelf boost to compensate for baffle step diffraction loss. + * Updates band 12 on the left channel. + * + * @param width_cm Baffle width in centimeters (0 = disabled) + * @param placement Room placement affecting compensation amount + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_baffle_step(uint8_t width_cm, tas5805m_baffle_placement_t placement, uint32_t sample_rate); + +/** + * @brief Apply tweeter breakup notch filter + * + * Narrow-band cut to suppress the tweeter's breakup resonance peak. + * Updates band 12 on the right channel. + * + * @param freq Notch center frequency in Hz (0 = disabled) + * @param gain_x2 Gain × 2 (should be negative for cut) + * @param q_x10 Q factor × 10 (e.g., 50 = Q of 5.0) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_notch(uint16_t freq, int8_t gain_x2, uint8_t q_x10, uint32_t sample_rate); + +/** + * @brief Apply air/brilliance high shelf + * + * High frequency shelf at 10kHz for treble presence adjustment. + * Updates band 13 on the right channel. + * + * @param gain_x2 Gain × 2 for 0.5dB resolution (0 = disabled) + * @param sample_rate Current sample rate in Hz + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_apply_air_shelf(int8_t gain_x2, uint32_t sample_rate); + +#endif /* CONFIG_DAC_TAS5805M */ + +#ifdef __cplusplus +} +#endif + +#endif /* __TAS5805M_BIAMP_H__ */ diff --git a/components/tas5805m_settings/include/tas5805m_settings.h b/components/tas5805m_settings/include/tas5805m_settings.h index c5412f53..47fd9bb6 100644 --- a/components/tas5805m_settings/include/tas5805m_settings.h +++ b/components/tas5805m_settings/include/tas5805m_settings.h @@ -46,6 +46,42 @@ extern "C" { #define TAS5805M_NVS_KEY_CHANNEL_GAIN_L "channel_gain_l" #define TAS5805M_NVS_KEY_CHANNEL_GAIN_R "channel_gain_r" +// Advanced Bi-Amp crossover NVS keys +#define TAS5805M_NVS_KEY_BIAMP_XOVER_FREQ "biamp_xfreq" +#define TAS5805M_NVS_KEY_BIAMP_SLOPE "biamp_slope" +#define TAS5805M_NVS_KEY_BIAMP_TYPE "biamp_type" +#define TAS5805M_NVS_KEY_BIAMP_LOW_GAIN "biamp_lo_g" +#define TAS5805M_NVS_KEY_BIAMP_LOW_PHASE "biamp_lo_ph" +#define TAS5805M_NVS_KEY_BIAMP_HIGH_GAIN "biamp_hi_g" +#define TAS5805M_NVS_KEY_BIAMP_HIGH_PHASE "biamp_hi_ph" +#define TAS5805M_NVS_KEY_BIAMP_SAMPLE_RATE "biamp_sr" +// Subsonic filter (high-pass) for speaker protection +#define TAS5805M_NVS_KEY_BIAMP_SUBSONIC_FREQ "biamp_sub_f" +// Per-output PEQ keys (format: biamp_lo_peqN_f, biamp_lo_peqN_g, biamp_lo_peqN_q) +#define TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L "biamp_lo_peq" +#define TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H "biamp_hi_peq" + +// Loudness compensation (volume-dependent EQ overlay) +#define TAS5805M_NVS_KEY_LOUDNESS_ENABLED "loud_en" +#define TAS5805M_NVS_KEY_LOUDNESS_THRESH "loud_thr" // 4 bytes for thresholds +#define TAS5805M_NVS_KEY_LOUDNESS_BASS "loud_bass" // 5 bytes for bass boost +#define TAS5805M_NVS_KEY_LOUDNESS_TREBLE "loud_treb" // 5 bytes for treble boost + +// Baffle step compensation (woofer low shelf) +#define TAS5805M_NVS_KEY_BAFFLE_WIDTH "baffle_w" // Baffle width in cm +#define TAS5805M_NVS_KEY_BAFFLE_PLACEMENT "baffle_p" // Speaker placement type + +// Time alignment (tweeter delay) +#define TAS5805M_NVS_KEY_TWEETER_DELAY "twt_delay" // Tweeter delay in mm + +// Tweeter breakup notch filter +#define TAS5805M_NVS_KEY_NOTCH_FREQ "twt_notch_f" // Notch frequency in Hz +#define TAS5805M_NVS_KEY_NOTCH_GAIN "twt_notch_g" // Notch depth (negative dB) +#define TAS5805M_NVS_KEY_NOTCH_Q "twt_notch_q" // Notch Q factor x10 + +// Air/Brilliance shelf (tweeter high shelf) +#define TAS5805M_NVS_KEY_AIR_GAIN "twt_air_g" // Air shelf gain in dB x2 + /** EQ UI modes exposed to the settings UI. These control visibility and apply behavior. * Defined here so the settings module owns the UI contract. Values are persisted to NVS. */ @@ -54,6 +90,7 @@ typedef enum { TAS5805M_EQ_UI_MODE_15_BAND = 1, TAS5805M_EQ_UI_MODE_15_BAND_BIAMP = 2, TAS5805M_EQ_UI_MODE_PRESETS = 3, + TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP = 4, } TAS5805M_EQ_UI_MODE; /** Convert an EQ UI mode to human-readable name (for schema name fields) */ @@ -124,6 +161,124 @@ esp_err_t tas5805m_settings_save_channel_gain(tas5805m_eq_chan_t ch, int gain_db /** Load per-output channel gain (single value per channel, in dB) */ esp_err_t tas5805m_settings_load_channel_gain(tas5805m_eq_chan_t ch, int *gain_db); +/* ============ Advanced Bi-Amp Crossover Types ============ */ + +/** Crossover filter slope (number of cascaded biquads) */ +typedef enum { + BIAMP_SLOPE_12DB = 1, // 12 dB/octave (1 biquad) + BIAMP_SLOPE_24DB = 2, // 24 dB/octave (2 biquads, Linkwitz-Riley) + BIAMP_SLOPE_48DB = 4, // 48 dB/octave (4 biquads) +} tas5805m_biamp_slope_t; + +/** Crossover filter type */ +typedef enum { + BIAMP_TYPE_BUTTERWORTH = 0, + BIAMP_TYPE_LINKWITZ_RILEY = 1, +} tas5805m_biamp_type_t; + +/** Baffle step speaker placement - affects compensation amount */ +typedef enum { + BAFFLE_PLACEMENT_FREESTANDING = 0, // Full 6dB compensation + BAFFLE_PLACEMENT_NEAR_WALL = 1, // ~3dB compensation (wall reflection helps) + BAFFLE_PLACEMENT_CORNER = 2, // No compensation needed (room gain) +} tas5805m_baffle_placement_t; + +/** Per-output PEQ band settings */ +typedef struct { + uint16_t freq; // Center frequency (20-20000 Hz), 0 = disabled + int8_t gain; // Gain * 2 for 0.5 dB resolution (-30 to +30 representing -15 to +15 dB) + uint8_t q_x10; // Q factor * 10 (5-100 representing 0.5-10.0) +} tas5805m_biamp_peq_band_t; + +#define TAS5805M_BIAMP_PEQ_BANDS 6 // PEQ bands per output + +/** Advanced bi-amp crossover settings */ +typedef struct { + // Crossover settings + uint16_t crossover_freq; // 20-20000 Hz + tas5805m_biamp_slope_t slope; // 12/24/48 dB/oct + tas5805m_biamp_type_t type; // Butterworth/Linkwitz-Riley + uint32_t sample_rate; // Sample rate in Hz (44100, 48000, 88200, 96000) + + // Speaker protection + uint16_t subsonic_freq; // Subsonic HPF frequency (0=off, 20-80 Hz typical) + + // Low output (woofer) settings + int8_t low_gain; // Gain * 2 for 0.5 dB resolution (-48 to +48 representing -24 to +24 dB) + uint8_t low_phase_invert; // 0=normal, 1=invert + tas5805m_biamp_peq_band_t low_peq[TAS5805M_BIAMP_PEQ_BANDS]; + + // High output (tweeter) settings + int8_t high_gain; // Gain * 2 for 0.5 dB resolution (-48 to +48 representing -24 to +24 dB) + uint8_t high_phase_invert; // 0=normal, 1=invert + tas5805m_biamp_peq_band_t high_peq[TAS5805M_BIAMP_PEQ_BANDS]; + + // Baffle step compensation (low shelf boost for woofer) + uint8_t baffle_width_cm; // 0=disabled, 5-50cm typical + tas5805m_baffle_placement_t baffle_placement; // Affects compensation amount + + // Time alignment (delays tweeter to align with woofer) + uint8_t tweeter_delay_mm; // 0=disabled, 1-50mm typical + + // Tweeter breakup notch (tames resonance peak at tweeter's upper limit) + uint16_t notch_freq; // 0=disabled, 10000-25000 Hz typical + int8_t notch_gain; // Gain * 2 for 0.5 dB resolution (0 to -24 representing 0 to -12 dB) + uint8_t notch_q_x10; // Q factor * 10 (20-100 representing 2.0-10.0) + + // Air/Brilliance shelf (high shelf for treble sparkle) + int8_t air_gain; // Gain * 2 for 0.5 dB resolution (-12 to +12 representing -6 to +6 dB) +} tas5805m_biamp_settings_t; + +/** Default bi-amp settings */ +#define TAS5805M_BIAMP_DEFAULT_XOVER_FREQ 2000 +#define TAS5805M_BIAMP_DEFAULT_SLOPE BIAMP_SLOPE_24DB +#define TAS5805M_BIAMP_DEFAULT_TYPE BIAMP_TYPE_LINKWITZ_RILEY + +/** Baffle step compensation - uses woofer band 12 (spare) */ +#define TAS5805M_BAFFLE_STEP_BAND 12 +#define TAS5805M_BAFFLE_DEFAULT_WIDTH 0 // Disabled by default + +/** Save advanced bi-amp settings to NVS */ +esp_err_t tas5805m_settings_save_biamp(const tas5805m_biamp_settings_t *settings); +/** Load advanced bi-amp settings from NVS */ +esp_err_t tas5805m_settings_load_biamp(tas5805m_biamp_settings_t *settings); +/** Apply advanced bi-amp settings to the DAC */ +esp_err_t tas5805m_settings_apply_biamp(const tas5805m_biamp_settings_t *settings); + +/* ============ Loudness Compensation (Volume-Dependent EQ) ============ */ + +#define TAS5805M_LOUDNESS_ZONES 5 /** Number of volume zones */ + +/** Loudness compensation settings - applies bass/treble boost based on volume */ +typedef struct { + uint8_t enabled; // 0=off, 1=on + uint8_t thresholds[TAS5805M_LOUDNESS_ZONES-1]; // 4 thresholds (0-100), e.g. {20,40,60,80} + int8_t bass_boost[TAS5805M_LOUDNESS_ZONES]; // Bass boost per zone in dB (-12 to +12) + int8_t treble_boost[TAS5805M_LOUDNESS_ZONES]; // Treble boost per zone in dB (-12 to +12) +} tas5805m_loudness_settings_t; + +/** Default loudness thresholds (volume %) */ +#define TAS5805M_LOUDNESS_DEFAULT_THRESH {20, 40, 60, 80} +/** Default bass boost per zone (dB) - more boost at lower volumes */ +#define TAS5805M_LOUDNESS_DEFAULT_BASS {6, 4, 2, 1, 0} +/** Default treble boost per zone (dB) */ +#define TAS5805M_LOUDNESS_DEFAULT_TREBLE {4, 3, 2, 1, 0} + +/** Biquad bands used for loudness shelving filters */ +#define TAS5805M_LOUDNESS_BASS_BAND 13 // Low shelf filter band +#define TAS5805M_LOUDNESS_TREBLE_BAND 14 // High shelf filter band + +/** Save loudness settings to NVS */ +esp_err_t tas5805m_settings_save_loudness(const tas5805m_loudness_settings_t *settings); +/** Load loudness settings from NVS */ +esp_err_t tas5805m_settings_load_loudness(tas5805m_loudness_settings_t *settings); +/** Initialize loudness settings to defaults */ +void tas5805m_loudness_init_defaults(tas5805m_loudness_settings_t *settings); +/** Apply loudness compensation based on current volume (0-100) */ +esp_err_t tas5805m_loudness_apply(int volume); +/** Get current loudness zone (0-4) for a given volume */ +int tas5805m_loudness_get_zone(int volume); + /** Get current TAS5805M settings as a JSON string */ //esp_err_t tas5805m_settings_get_json(char *json_out, size_t max_len); @@ -162,6 +317,44 @@ esp_err_t tas5805m_settings_apply_early(void); */ esp_err_t tas5805m_settings_apply_delayed(void); +/* ============ Bi-Amp Preset Export/Import ============ */ + +/** Preset version for compatibility checking */ +#define TAS5805M_BIAMP_PRESET_VERSION 1 + +/** + * @brief Export current bi-amp settings to JSON preset format + * + * Creates a portable JSON preset that can be shared with 3D speaker models. + * Includes crossover, per-output gains/PEQ, phase, and loudness settings. + * + * @param json_out Buffer to write JSON string + * @param max_len Maximum buffer size + * @return ESP_OK on success, error code otherwise + */ +esp_err_t tas5805m_biamp_preset_export(char *json_out, size_t max_len); + +/** + * @brief Import bi-amp settings from JSON preset + * + * Parses and validates a preset JSON, then applies and saves the settings. + * + * @param json_in JSON preset string + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if preset is invalid + */ +esp_err_t tas5805m_biamp_preset_import(const char *json_in); + +/** + * @brief Reset bi-amp settings to factory defaults + * + * Clears all bi-amp related NVS keys and re-initializes settings to defaults. + * This includes crossover, gains, PEQ, phase, loudness, and all other bi-amp + * parameters. + * + * @return ESP_OK on success + */ +esp_err_t tas5805m_biamp_reset_defaults(void); + #endif /* CONFIG_DAC_TAS5805M */ #ifdef __cplusplus diff --git a/components/tas5805m_settings/tas5805m_biamp.c b/components/tas5805m_settings/tas5805m_biamp.c new file mode 100644 index 00000000..03cfc2b6 --- /dev/null +++ b/components/tas5805m_settings/tas5805m_biamp.c @@ -0,0 +1,1258 @@ +/** + * @file tas5805m_biamp.c + * @brief Advanced Bi-Amp Active Crossover Implementation for TAS5805M + * + * This module implements digital biquad filter coefficient calculations for + * active bi-amplification using the TAS5805M DAC's 15-band parametric EQ. + * Each channel (left=woofer, right=tweeter) receives independent filter + * processing computed using the bilinear transform method. + * + * @section filter_algorithms Filter Algorithms + * + * All filters use the bilinear transform to convert analog filter prototypes + * to digital IIR filters. The general biquad transfer function is: + * + * b0 + b1*z^-1 + b2*z^-2 + * H(z) = ------------------------- + * 1 + a1*z^-1 + a2*z^-2 + * + * Filter coefficient formulas are based on: + * - "Cookbook formulae for audio EQ biquad filter coefficients" by Robert Bristow-Johnson + * - Butterworth and Linkwitz-Riley crossover design theory + * + * @section coefficient_format Coefficient Format + * + * The TAS5805M uses Q5.27 fixed-point format for biquad coefficients: + * - Range: -16.0 to +15.9999999925 + * - Resolution: ~7.45e-9 + * - Conversion: coefficient_fixed = (int32_t)(coefficient_float * 134217728.0) + * + * @copyright Copyright (c) 2024 + */ + +#include "tas5805m_biamp.h" + +/* Biamp requires both TAS5805M DAC and EQ support for biquad coefficient writing */ +#if CONFIG_DAC_TAS5805M && CONFIG_DAC_TAS5805M_EQ_SUPPORT + +#include +#include +#include "esp_log.h" +#include "tas5805m.h" + +static const char *TAG = "tas5805m_biamp"; + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#ifndef M_SQRT2 +#define M_SQRT2 1.41421356237309504880 +#endif + +/* ============================================================================ + * Core Filter Coefficient Calculations + * ============================================================================ */ + +/** + * @brief Calculate 2nd-order Butterworth lowpass filter coefficients + * + * Implements the bilinear transform of the analog Butterworth prototype: + * + * 1 + * H(s) = ───────────────── + * s² + √2·s + 1 + * + * The bilinear transform uses frequency pre-warping to maintain accurate + * cutoff frequency mapping from analog to digital domain: + * + * K = tan(π·fc/fs) where fc is cutoff frequency, fs is sample rate + * + * @param fc Cutoff frequency in Hz (must be < fs/2) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_butterworth_lpf(float fc, float fs, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL || fc <= 0 || fs <= 0 || fc >= fs / 2) { + return ESP_ERR_INVALID_ARG; + } + + /* Pre-warp the cutoff frequency for bilinear transform */ + float w0 = 2.0f * M_PI * fc / fs; + float K = tanf(w0 / 2.0f); + float K2 = K * K; + float sqrt2_K = M_SQRT2 * K; + + /* Calculate normalized coefficients (a0 = 1) */ + float norm = 1.0f / (1.0f + sqrt2_K + K2); + + coeffs->b0 = K2 * norm; + coeffs->b1 = 2.0f * coeffs->b0; + coeffs->b2 = coeffs->b0; + coeffs->a1 = 2.0f * (K2 - 1.0f) * norm; + coeffs->a2 = (1.0f - sqrt2_K + K2) * norm; + + return ESP_OK; +} + +/** + * @brief Calculate 2nd-order Butterworth highpass filter coefficients + * + * Implements the bilinear transform of the analog Butterworth HPF prototype: + * + * s² + * H(s) = ───────────────── + * s² + √2·s + 1 + * + * Uses the same pre-warping technique as the lowpass variant. The highpass + * response is obtained by transforming s → 1/s in the lowpass prototype. + * + * @param fc Cutoff frequency in Hz (must be < fs/2) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_butterworth_hpf(float fc, float fs, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL || fc <= 0 || fs <= 0 || fc >= fs / 2) { + return ESP_ERR_INVALID_ARG; + } + + /* Pre-warp the cutoff frequency for bilinear transform */ + float w0 = 2.0f * M_PI * fc / fs; + float K = tanf(w0 / 2.0f); + float K2 = K * K; + float sqrt2_K = M_SQRT2 * K; + + /* Calculate normalized coefficients (a0 = 1) */ + float norm = 1.0f / (1.0f + sqrt2_K + K2); + + coeffs->b0 = norm; + coeffs->b1 = -2.0f * norm; + coeffs->b2 = norm; + coeffs->a1 = 2.0f * (K2 - 1.0f) * norm; + coeffs->a2 = (1.0f - sqrt2_K + K2) * norm; + + return ESP_OK; +} + +/** + * @brief Calculate parametric EQ (peaking) filter coefficients + * + * Implements a 2nd-order parametric equalizer using the Audio EQ Cookbook + * formula. This filter provides symmetric boost/cut around a center frequency. + * + * The Q parameter controls bandwidth: BW = fc/Q (in octaves: ~1.4/Q) + * + * Common Q values: + * - Q = 0.5: Very wide (2.8 octaves) + * - Q = 1.0: Moderate (1.4 octaves) + * - Q = 2.0: Narrow (0.7 octaves) + * - Q = 5.0: Very narrow (0.3 octaves) + * + * @param fc Center frequency in Hz (must be < fs/2) + * @param gain_db Gain in dB (positive = boost, negative = cut) + * @param q Quality factor (bandwidth control, must be > 0) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_peq(float fc, float gain_db, float q, float fs, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL || fc <= 0 || fs <= 0 || fc >= fs / 2 || q <= 0) { + return ESP_ERR_INVALID_ARG; + } + + /* Zero gain means passthrough - avoid unnecessary computation */ + if (fabsf(gain_db) < 0.01f) { + return tas5805m_calc_passthrough(coeffs); + } + + /* A = sqrt(10^(dB/20)) = 10^(dB/40) */ + float A = powf(10.0f, gain_db / 40.0f); + float w0 = 2.0f * M_PI * fc / fs; + float sin_w0 = sinf(w0); + float cos_w0 = cosf(w0); + float alpha = sin_w0 / (2.0f * q); + + /* Peaking EQ coefficients from Audio EQ Cookbook */ + float b0 = 1.0f + alpha * A; + float b1 = -2.0f * cos_w0; + float b2 = 1.0f - alpha * A; + float a0 = 1.0f + alpha / A; + float a1 = -2.0f * cos_w0; + float a2 = 1.0f - alpha / A; + + /* Normalize by a0 to get standard biquad form */ + coeffs->b0 = b0 / a0; + coeffs->b1 = b1 / a0; + coeffs->b2 = b2 / a0; + coeffs->a1 = a1 / a0; + coeffs->a2 = a2 / a0; + + return ESP_OK; +} + +/** + * @brief Calculate simple gain stage coefficients + * + * Creates a first-order gain element that applies uniform gain across all + * frequencies. Uses only b0 coefficient with all others set to zero. + * + * The transfer function is simply: H(z) = 10^(dB/20) + * + * @param gain_db Gain in dB (positive = boost, negative = cut) + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if coeffs is NULL + */ +esp_err_t tas5805m_calc_gain(float gain_db, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL) { + return ESP_ERR_INVALID_ARG; + } + + float linear_gain = powf(10.0f, gain_db / 20.0f); + + coeffs->b0 = linear_gain; + coeffs->b1 = 0.0f; + coeffs->b2 = 0.0f; + coeffs->a1 = 0.0f; + coeffs->a2 = 0.0f; + + return ESP_OK; +} + +/** + * @brief Calculate unity passthrough coefficients + * + * Sets coefficients for a transparent passthrough (unity gain, no filtering). + * H(z) = 1 (b0=1, all other coefficients = 0) + * + * Used to disable filter bands while maintaining DSP chain integrity. + * + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if coeffs is NULL + */ +esp_err_t tas5805m_calc_passthrough(tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL) { + return ESP_ERR_INVALID_ARG; + } + + coeffs->b0 = 1.0f; + coeffs->b1 = 0.0f; + coeffs->b2 = 0.0f; + coeffs->a1 = 0.0f; + coeffs->a2 = 0.0f; + + return ESP_OK; +} + +/** + * @brief Calculate phase inversion coefficients + * + * Creates a 180-degree phase shift (polarity inversion) by setting b0 = -1. + * H(z) = -1 + * + * Used for driver polarity correction when physical wiring cannot be changed, + * or to achieve acoustic phase alignment at the crossover point. + * + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if coeffs is NULL + */ +esp_err_t tas5805m_calc_phase_invert(tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL) { + return ESP_ERR_INVALID_ARG; + } + + coeffs->b0 = -1.0f; + coeffs->b1 = 0.0f; + coeffs->b2 = 0.0f; + coeffs->a1 = 0.0f; + coeffs->a2 = 0.0f; + + return ESP_OK; +} + +/** + * @brief Calculate low shelf filter coefficients + * + * Implements a 2nd-order low shelf filter using the Audio EQ Cookbook formula. + * Boosts or cuts frequencies below the corner frequency while leaving higher + * frequencies unaffected. + * + * The shelf slope parameter S is fixed at 1.0, providing a moderate transition. + * At the corner frequency, gain is half the specified value (in dB). + * + * Used for baffle step compensation and bass adjustments. + * + * @param fc Corner frequency in Hz (must be < fs/2) + * @param gain_db Gain in dB (positive = boost, negative = cut) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_low_shelf(float fc, float gain_db, float fs, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL || fc <= 0 || fs <= 0 || fc >= fs / 2) { + return ESP_ERR_INVALID_ARG; + } + + /* Zero gain means passthrough - avoid unnecessary computation */ + if (fabsf(gain_db) < 0.01f) { + return tas5805m_calc_passthrough(coeffs); + } + + /* A = sqrt(10^(dB/20)) = 10^(dB/40) */ + float A = powf(10.0f, gain_db / 40.0f); + float w0 = 2.0f * M_PI * fc / fs; + float sin_w0 = sinf(w0); + float cos_w0 = cosf(w0); + + /* Shelf slope S=1 simplifies to: alpha = sin(w0)/2 * sqrt(2) */ + float alpha = sin_w0 / 2.0f * M_SQRT2; + float two_sqrt_A_alpha = 2.0f * sqrtf(A) * alpha; + + /* Low shelf coefficients from Audio EQ Cookbook */ + float b0 = A * ((A + 1.0f) - (A - 1.0f) * cos_w0 + two_sqrt_A_alpha); + float b1 = 2.0f * A * ((A - 1.0f) - (A + 1.0f) * cos_w0); + float b2 = A * ((A + 1.0f) - (A - 1.0f) * cos_w0 - two_sqrt_A_alpha); + float a0 = (A + 1.0f) + (A - 1.0f) * cos_w0 + two_sqrt_A_alpha; + float a1 = -2.0f * ((A - 1.0f) + (A + 1.0f) * cos_w0); + float a2 = (A + 1.0f) + (A - 1.0f) * cos_w0 - two_sqrt_A_alpha; + + /* Normalize by a0 */ + coeffs->b0 = b0 / a0; + coeffs->b1 = b1 / a0; + coeffs->b2 = b2 / a0; + coeffs->a1 = a1 / a0; + coeffs->a2 = a2 / a0; + + return ESP_OK; +} + +/** + * @brief Calculate high shelf filter coefficients + * + * Implements a 2nd-order high shelf filter using the Audio EQ Cookbook formula. + * Boosts or cuts frequencies above the corner frequency while leaving lower + * frequencies unaffected. + * + * The shelf slope parameter S is fixed at 1.0, providing a moderate transition. + * At the corner frequency, gain is half the specified value (in dB). + * + * Used for air/brilliance adjustments and loudness compensation treble boost. + * + * @param fc Corner frequency in Hz (must be < fs/2) + * @param gain_db Gain in dB (positive = boost, negative = cut) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_high_shelf(float fc, float gain_db, float fs, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL || fc <= 0 || fs <= 0 || fc >= fs / 2) { + return ESP_ERR_INVALID_ARG; + } + + /* Zero gain means passthrough - avoid unnecessary computation */ + if (fabsf(gain_db) < 0.01f) { + return tas5805m_calc_passthrough(coeffs); + } + + /* A = sqrt(10^(dB/20)) = 10^(dB/40) */ + float A = powf(10.0f, gain_db / 40.0f); + float w0 = 2.0f * M_PI * fc / fs; + float sin_w0 = sinf(w0); + float cos_w0 = cosf(w0); + + /* Shelf slope S=1 simplifies to: alpha = sin(w0)/2 * sqrt(2) */ + float alpha = sin_w0 / 2.0f * M_SQRT2; + float two_sqrt_A_alpha = 2.0f * sqrtf(A) * alpha; + + /* High shelf coefficients from Audio EQ Cookbook */ + float b0 = A * ((A + 1.0f) + (A - 1.0f) * cos_w0 + two_sqrt_A_alpha); + float b1 = -2.0f * A * ((A - 1.0f) + (A + 1.0f) * cos_w0); + float b2 = A * ((A + 1.0f) + (A - 1.0f) * cos_w0 - two_sqrt_A_alpha); + float a0 = (A + 1.0f) - (A - 1.0f) * cos_w0 + two_sqrt_A_alpha; + float a1 = 2.0f * ((A - 1.0f) - (A + 1.0f) * cos_w0); + float a2 = (A + 1.0f) - (A - 1.0f) * cos_w0 - two_sqrt_A_alpha; + + /* Normalize by a0 */ + coeffs->b0 = b0 / a0; + coeffs->b1 = b1 / a0; + coeffs->b2 = b2 / a0; + coeffs->a1 = a1 / a0; + coeffs->a2 = a2 / a0; + + return ESP_OK; +} + +/* ============================================================================ + * Specialized Filter Calculations + * ============================================================================ */ + +/** + * @brief Calculate baffle step compensation parameters + * + * Baffle step is an acoustic phenomenon where low frequencies radiate + * omnidirectionally (wrapping around the speaker baffle) while high + * frequencies beam forward. This causes a 3-6dB step in SPL response. + * + * The transition frequency depends on baffle width: + * f_step = c / (π × w) + * where c = 343 m/s (speed of sound at ~20°C) and w = baffle width in meters. + * + * Example frequencies for common baffle widths: + * - 15cm baffle: ~728 Hz + * - 20cm baffle: ~546 Hz + * - 30cm baffle: ~364 Hz + * + * Room placement affects the required compensation: + * - Freestanding: Full 6dB boost (no boundary reinforcement) + * - Near wall: 3dB boost (single boundary adds ~3dB at low frequencies) + * - Corner: 0dB boost (two boundaries provide full reinforcement) + * + * @param baffle_width_cm Baffle width in centimeters (0 = disabled, 5-50cm valid) + * @param placement Speaker placement relative to room boundaries + * @param step_freq_hz Output: calculated step frequency in Hz (may be NULL) + * @param gain_db Output: recommended compensation gain in dB (may be NULL) + */ +void tas5805m_calc_baffle_step(uint8_t baffle_width_cm, + tas5805m_baffle_placement_t placement, + float *step_freq_hz, float *gain_db) +{ + /* Initialize outputs to disabled state */ + if (step_freq_hz) *step_freq_hz = 0.0f; + if (gain_db) *gain_db = 0.0f; + + /* Width of 0 means disabled */ + if (baffle_width_cm == 0) { + return; + } + + /* Convert cm to meters and clamp to reasonable range */ + float width_m = (float)baffle_width_cm / 100.0f; + if (width_m < 0.05f) width_m = 0.05f; /* Minimum 5cm */ + if (width_m > 0.50f) width_m = 0.50f; /* Maximum 50cm */ + + /* Calculate step frequency: f = c / (π × w) */ + float freq = 343.0f / (M_PI * width_m); + + /* Determine compensation gain based on room placement */ + float gain; + switch (placement) { + case BAFFLE_PLACEMENT_FREESTANDING: + gain = 6.0f; /* Full compensation needed */ + break; + case BAFFLE_PLACEMENT_NEAR_WALL: + gain = 3.0f; /* Wall provides partial bass reinforcement */ + break; + case BAFFLE_PLACEMENT_CORNER: + default: + gain = 0.0f; /* Corner placement provides full reinforcement */ + break; + } + + if (step_freq_hz) *step_freq_hz = freq; + if (gain_db) *gain_db = gain; + + ESP_LOGD(TAG, "Baffle step: width=%dcm placement=%d -> freq=%.1fHz gain=%.1fdB", + baffle_width_cm, placement, freq, gain); +} + +/** + * @brief Calculate first-order all-pass filter for time alignment + * + * Creates frequency-dependent phase shift (group delay) to compensate for + * physical driver offset. This is an approximation suitable for small delays + * where pure sample delay would require fractional samples. + * + * Transfer function: H(z) = (a + z^-1) / (1 + a·z^-1) + * where a = (1 - tan(π·fc/fs)) / (1 + tan(π·fc/fs)) + * + * The group delay at DC approaches 1/(π·fc), so for a desired delay T: + * fc = 1 / (π × T) + * + * Physical offset to time conversion: + * T = d / c where d = offset distance, c = 343000 mm/s + * + * Typical tweeter offset values: + * - 10mm: ~29 µs delay + * - 25mm: ~73 µs delay + * - 50mm: ~146 µs delay + * + * @note This is a first-order approximation. For precise alignment, physical + * driver positioning or dedicated delay DSP blocks are preferable. + * + * @param delay_mm Physical offset to compensate in millimeters (0 = passthrough) + * @param fs Sample rate in Hz + * @param coeffs Output coefficient structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if parameters invalid + */ +esp_err_t tas5805m_calc_allpass_delay(uint8_t delay_mm, float fs, tas5805m_biquad_coeffs_t *coeffs) +{ + if (coeffs == NULL || fs <= 0) { + return ESP_ERR_INVALID_ARG; + } + + /* No delay requested - return passthrough */ + if (delay_mm == 0) { + return tas5805m_calc_passthrough(coeffs); + } + + /* Convert millimeters to seconds: t = d / (343000 mm/s) */ + float delay_sec = (float)delay_mm / 343000.0f; + + /* Calculate corner frequency for desired delay: fc = 1 / (π × t) */ + float fc = 1.0f / (M_PI * delay_sec); + + /* Clamp corner frequency to valid digital filter range */ + if (fc < 20.0f) fc = 20.0f; + if (fc >= fs / 2.0f) fc = fs / 2.0f - 1.0f; + + /* First-order all-pass coefficient calculation */ + float w0 = 2.0f * M_PI * fc / fs; + float tan_w0_2 = tanf(w0 / 2.0f); + float a = (1.0f - tan_w0_2) / (1.0f + tan_w0_2); + + /* Express as biquad with b2=0, a2=0 (first-order in biquad structure) */ + coeffs->b0 = a; + coeffs->b1 = 1.0f; + coeffs->b2 = 0.0f; + coeffs->a1 = a; + coeffs->a2 = 0.0f; + + ESP_LOGD(TAG, "Tweeter delay: %dmm -> %.1fus, fc=%.0fHz, a=%.6f", + delay_mm, delay_sec * 1000000.0f, fc, a); + + return ESP_OK; +} + +/* ============================================================================ + * Utility Functions + * ============================================================================ */ + +/** + * @brief Get number of biquad stages required for a crossover slope + * + * Crossover slopes are achieved by cascading multiple 2nd-order filter sections: + * - 12 dB/octave: 1 biquad (2nd order) + * - 24 dB/octave: 2 biquads (4th order) - Linkwitz-Riley alignment + * - 48 dB/octave: 4 biquads (8th order) - Steep, brick-wall rolloff + * + * @param slope Desired crossover slope + * @return Number of biquad stages (1, 2, or 4) + */ +int tas5805m_biamp_get_biquad_count(tas5805m_biamp_slope_t slope) +{ + switch (slope) { + case BIAMP_SLOPE_12DB: return 1; + case BIAMP_SLOPE_24DB: return 2; + case BIAMP_SLOPE_48DB: return 4; + default: return 2; /* Default to 24 dB/octave */ + } +} + +/** + * @brief Write biquad coefficients to a specific DSP band + * + * Internal helper that wraps the low-level coefficient write function. + * The TAS5805M accepts coefficient writes without inter-write delays. + * + * @param channel Target channel (LEFT or RIGHT) + * @param band Band index (0-14) + * @param coeffs Coefficient structure to write + * @return ESP_OK on success, error code on I2C failure + */ +static esp_err_t write_biquad_band(TAS5805M_EQ_CHANNELS channel, int band, + const tas5805m_biquad_coeffs_t *coeffs) +{ + return tas5805m_write_biquad_coefficients(channel, band, + coeffs->b0, coeffs->b1, coeffs->b2, + coeffs->a1, coeffs->a2); +} + +/** + * @brief Validate and normalize sample rate to supported value + * + * Ensures the sample rate is one of the supported values for coefficient + * calculations. Invalid rates default to 48000 Hz with a warning log. + * + * @param sample_rate Input sample rate to validate + * @return Validated sample rate (one of 44100, 48000, 88200, 96000) + */ +static uint32_t validate_sample_rate(uint32_t sample_rate) +{ + switch (sample_rate) { + case BIAMP_SAMPLE_RATE_44100: + case BIAMP_SAMPLE_RATE_48000: + case BIAMP_SAMPLE_RATE_88200: + case BIAMP_SAMPLE_RATE_96000: + return sample_rate; + default: + ESP_LOGW(TAG, "Invalid sample rate %lu, using default %d", + (unsigned long)sample_rate, TAS5805M_BIAMP_DEFAULT_SAMPLE_RATE); + return TAS5805M_BIAMP_DEFAULT_SAMPLE_RATE; + } +} + +/* ============================================================================ + * Full Configuration Application + * ============================================================================ */ + +/** + * @brief Apply complete bi-amp crossover configuration to TAS5805M + * + * Configures all 15 biquad bands on both channels for active bi-amplification. + * This is the main entry point for applying a complete crossover setup. + * + * Band allocation per channel: + * + * LEFT (Woofer/Low output): + * - Band 0: Gain + optional phase inversion + * - Band 1: Subsonic highpass filter (rumble protection) + * - Bands 2-5: Lowpass crossover filter stages + * - Bands 6-8: Parametric EQ (3 bands for driver correction) + * - Band 12: Baffle step compensation (low shelf boost) + * - Bands 13-14: Reserved for loudness compensation + * + * RIGHT (Tweeter/High output): + * - Band 0: Gain + optional phase inversion + * - Band 1: Time alignment (all-pass delay approximation) + * - Bands 2-5: Highpass crossover filter stages + * - Bands 6-8: Parametric EQ (3 bands for driver correction) + * - Band 12: Tweeter breakup notch filter + * - Band 13: Air/brilliance high shelf + * - Band 14: Reserved for loudness compensation + * + * @param settings Pointer to complete bi-amp settings structure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if settings is NULL, + * or I2C error code on hardware communication failure + */ +esp_err_t tas5805m_biamp_apply(const tas5805m_biamp_settings_t *settings) +{ + if (settings == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t ret = ESP_OK; + tas5805m_biquad_coeffs_t coeffs; + int num_stages = tas5805m_biamp_get_biquad_count(settings->slope); + float fc = (float)settings->crossover_freq; + float fs = (float)validate_sample_rate(settings->sample_rate); + int band; + + /* Cascading identical BW2 stages produces Linkwitz-Riley alignment */ + ESP_LOGI(TAG, "Applying bi-amp crossover: fc=%dHz, slope=%ddB/oct, type=LR, fs=%.0fHz", + settings->crossover_freq, + num_stages * 12, + fs); + + /* ========== LEFT CHANNEL (WOOFER/LOW OUTPUT) ========== */ + + /* Band 0: Gain + optional phase inversion */ + float low_total_gain = (float)settings->low_gain / 2.0f; /* Convert from x2 format */ + if (settings->low_phase_invert) { + /* Combine gain and phase invert into single coefficient */ + low_total_gain = -powf(10.0f, low_total_gain / 20.0f); + coeffs.b0 = low_total_gain; + coeffs.b1 = 0.0f; + coeffs.b2 = 0.0f; + coeffs.a1 = 0.0f; + coeffs.a2 = 0.0f; + } else { + ret = tas5805m_calc_gain(low_total_gain, &coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, BIAMP_GAIN_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Band 1: Subsonic highpass filter (placed early for optimal headroom) */ + if (settings->subsonic_freq > 0 && settings->subsonic_freq < fs / 2) { + ret = tas5805m_calc_butterworth_hpf((float)settings->subsonic_freq, fs, &coeffs); + if (ret != ESP_OK) return ret; + ESP_LOGI(TAG, "Applying subsonic HPF at %d Hz (band %d)", settings->subsonic_freq, BIAMP_SUBSONIC_BAND); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, BIAMP_SUBSONIC_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Bands 2-5: Lowpass crossover filter stages */ + ret = tas5805m_calc_butterworth_lpf(fc, fs, &coeffs); + if (ret != ESP_OK) return ret; + + for (int i = 0; i < num_stages; i++) { + band = BIAMP_CROSSOVER_START_BAND + i; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* Fill unused crossover bands with passthrough */ + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + for (int i = num_stages; i < BIAMP_CROSSOVER_MAX_BANDS; i++) { + band = BIAMP_CROSSOVER_START_BAND + i; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* Bands 6-8: Parametric EQ for woofer correction */ + for (int i = 0; i < BIAMP_PEQ_BANDS; i++) { + band = BIAMP_PEQ_START_BAND + i; + const tas5805m_biamp_peq_band_t *peq = &settings->low_peq[i]; + + if (peq->freq > 0 && peq->freq < fs / 2 && peq->gain != 0) { + float q = (float)peq->q_x10 / 10.0f; + float gain_db = (float)peq->gain / 2.0f; + ret = tas5805m_calc_peq((float)peq->freq, gain_db, q, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* Band 12: Baffle step compensation (low shelf boost) */ + if (settings->baffle_width_cm > 0) { + float step_freq, step_gain; + tas5805m_calc_baffle_step(settings->baffle_width_cm, settings->baffle_placement, + &step_freq, &step_gain); + if (step_gain > 0.1f && step_freq > 0.0f && step_freq < fs / 2) { + ret = tas5805m_calc_low_shelf(step_freq, step_gain, fs, &coeffs); + if (ret != ESP_OK) return ret; + ESP_LOGI(TAG, "Applying baffle step: width=%dcm freq=%.0fHz gain=+%.1fdB (band %d)", + settings->baffle_width_cm, step_freq, step_gain, BIAMP_BAFFLE_STEP_BAND); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + } + } else { + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, BIAMP_BAFFLE_STEP_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Bands 13-14: Initialize to passthrough (may be overwritten by loudness) */ + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + for (band = TAS5805M_LOUDNESS_BASS_BAND; band <= TAS5805M_LOUDNESS_TREBLE_BAND; band++) { + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* ========== RIGHT CHANNEL (TWEETER/HIGH OUTPUT) ========== */ + + /* Band 0: Gain + optional phase inversion */ + float high_total_gain = (float)settings->high_gain / 2.0f; /* Convert from x2 format */ + if (settings->high_phase_invert) { + /* Combine gain and phase invert into single coefficient */ + high_total_gain = -powf(10.0f, high_total_gain / 20.0f); + coeffs.b0 = high_total_gain; + coeffs.b1 = 0.0f; + coeffs.b2 = 0.0f; + coeffs.a1 = 0.0f; + coeffs.a2 = 0.0f; + } else { + ret = tas5805m_calc_gain(high_total_gain, &coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_GAIN_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Band 1: Time alignment (all-pass delay approximation) */ + if (settings->tweeter_delay_mm > 0) { + ret = tas5805m_calc_allpass_delay(settings->tweeter_delay_mm, fs, &coeffs); + if (ret != ESP_OK) return ret; + ESP_LOGI(TAG, "Applying tweeter delay: %d mm (band %d)", + settings->tweeter_delay_mm, BIAMP_TWEETER_DELAY_BAND); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_TWEETER_DELAY_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Bands 2-5: Highpass crossover filter stages */ + ret = tas5805m_calc_butterworth_hpf(fc, fs, &coeffs); + if (ret != ESP_OK) return ret; + + for (int i = 0; i < num_stages; i++) { + band = BIAMP_CROSSOVER_START_BAND + i; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* Fill unused crossover bands with passthrough */ + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + for (int i = num_stages; i < BIAMP_CROSSOVER_MAX_BANDS; i++) { + band = BIAMP_CROSSOVER_START_BAND + i; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* Bands 6-8: Parametric EQ for tweeter correction */ + for (int i = 0; i < BIAMP_PEQ_BANDS; i++) { + band = BIAMP_PEQ_START_BAND + i; + const tas5805m_biamp_peq_band_t *peq = &settings->high_peq[i]; + + if (peq->freq > 0 && peq->freq < fs / 2 && peq->gain != 0) { + float q = (float)peq->q_x10 / 10.0f; + float gain_db = (float)peq->gain / 2.0f; + ret = tas5805m_calc_peq((float)peq->freq, gain_db, q, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, band, &coeffs); + if (ret != ESP_OK) return ret; + } + + /* Band 12: Tweeter breakup notch (narrow cut at resonance) */ + if (settings->notch_freq > 0 && settings->notch_gain < 0) { + float q = (float)settings->notch_q_x10 / 10.0f; + float gain_db = (float)settings->notch_gain / 2.0f; + ret = tas5805m_calc_peq((float)settings->notch_freq, gain_db, q, fs, &coeffs); + if (ret != ESP_OK) return ret; + ESP_LOGI(TAG, "Applying tweeter breakup notch: %dHz %.1fdB Q=%.1f (band %d)", + settings->notch_freq, gain_db, q, BIAMP_NOTCH_BAND); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_NOTCH_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Band 13: Air/brilliance high shelf (fixed 10kHz corner) */ + if (settings->air_gain != 0) { + float gain_db = (float)settings->air_gain / 2.0f; + ret = tas5805m_calc_high_shelf(10000.0f, gain_db, fs, &coeffs); + if (ret != ESP_OK) return ret; + ESP_LOGI(TAG, "Applying air/brilliance shelf: %.1fdB @ 10kHz (band %d)", + gain_db, BIAMP_AIR_SHELF_BAND); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + } + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_AIR_SHELF_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + /* Band 14: Initialize to passthrough (may be overwritten by loudness) */ + ret = tas5805m_calc_passthrough(&coeffs); + if (ret != ESP_OK) return ret; + ret = write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, TAS5805M_LOUDNESS_TREBLE_BAND, &coeffs); + if (ret != ESP_OK) return ret; + + ESP_LOGI(TAG, "Bi-amp crossover applied successfully (subsonic=%dHz)", settings->subsonic_freq); + return ESP_OK; +} + +/* ============================================================================ + * Individual Band Application Functions + * + * These functions allow updating single parameters without re-applying the + * entire crossover configuration. Useful for real-time adjustment of gain, + * phase, or EQ settings without audible glitches. + * ============================================================================ */ + +/** + * @brief Apply woofer gain and phase settings to Band 0 (left channel) + * + * Updates only the gain/phase band without affecting crossover or EQ settings. + * Phase inversion is combined with gain into a single coefficient. + * + * @param gain_x2 Gain in 0.5dB steps (-48 to +48 = -24dB to +24dB) + * @param phase_invert Non-zero to invert polarity (180° phase shift) + * @param sample_rate Current sample rate (used for validation only) + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_low_gain_phase(int8_t gain_x2, uint8_t phase_invert, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + + /* Clamp gain to valid range: -24dB to +24dB */ + if (gain_x2 < -48) gain_x2 = -48; + if (gain_x2 > 48) gain_x2 = 48; + + float gain_db = (float)gain_x2 / 2.0f; + esp_err_t ret; + + if (phase_invert) { + /* Combine negative gain with phase inversion */ + float linear_gain = -powf(10.0f, gain_db / 20.0f); + /* Clamp to Q5.27 representable range */ + if (linear_gain < -16.0f) linear_gain = -16.0f; + if (linear_gain > 15.999f) linear_gain = 15.999f; + coeffs.b0 = linear_gain; + coeffs.b1 = 0.0f; + coeffs.b2 = 0.0f; + coeffs.a1 = 0.0f; + coeffs.a2 = 0.0f; + } else { + ret = tas5805m_calc_gain(gain_db, &coeffs); + if (ret != ESP_OK) return ret; + } + + return write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, BIAMP_GAIN_BAND, &coeffs); +} + +/** + * @brief Apply tweeter gain and phase settings to Band 0 (right channel) + * + * Updates only the gain/phase band without affecting crossover or EQ settings. + * Phase inversion is combined with gain into a single coefficient. + * + * @param gain_x2 Gain in 0.5dB steps (-48 to +48 = -24dB to +24dB) + * @param phase_invert Non-zero to invert polarity (180° phase shift) + * @param sample_rate Current sample rate (used for validation only) + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_high_gain_phase(int8_t gain_x2, uint8_t phase_invert, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + + /* Clamp gain to valid range: -24dB to +24dB */ + if (gain_x2 < -48) gain_x2 = -48; + if (gain_x2 > 48) gain_x2 = 48; + + float gain_db = (float)gain_x2 / 2.0f; + esp_err_t ret; + + if (phase_invert) { + /* Combine negative gain with phase inversion */ + float linear_gain = -powf(10.0f, gain_db / 20.0f); + /* Clamp to Q5.27 representable range */ + if (linear_gain < -16.0f) linear_gain = -16.0f; + if (linear_gain > 15.999f) linear_gain = 15.999f; + coeffs.b0 = linear_gain; + coeffs.b1 = 0.0f; + coeffs.b2 = 0.0f; + coeffs.a1 = 0.0f; + coeffs.a2 = 0.0f; + } else { + ret = tas5805m_calc_gain(gain_db, &coeffs); + if (ret != ESP_OK) return ret; + } + + return write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_GAIN_BAND, &coeffs); +} + +/** + * @brief Apply subsonic highpass filter to Band 1 (left channel only) + * + * Protects woofer from infrasonic content that causes cone excursion without + * producing audible sound. Uses 2nd-order Butterworth response. + * + * Common subsonic frequencies: + * - 20 Hz: Minimal filtering, preserves deep bass + * - 30 Hz: Good protection for ported designs + * - 40 Hz: Aggressive protection for small woofers + * + * @param freq Cutoff frequency in Hz (0 = disabled/passthrough) + * @param sample_rate Current sample rate + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_subsonic(uint16_t freq, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + + if (freq > 0 && freq < fs / 2) { + ret = tas5805m_calc_butterworth_hpf((float)freq, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, BIAMP_SUBSONIC_BAND, &coeffs); +} + +/** + * @brief Apply tweeter time alignment delay to Band 1 (right channel only) + * + * Compensates for physical driver offset using all-pass filter approximation. + * This provides frequency-dependent phase shift that approximates a pure delay + * near the crossover region. + * + * @param delay_mm Physical offset in millimeters (0 = disabled/passthrough) + * @param sample_rate Current sample rate + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_tweeter_delay(uint8_t delay_mm, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + + if (delay_mm > 0) { + ret = tas5805m_calc_allpass_delay(delay_mm, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_TWEETER_DELAY_BAND, &coeffs); +} + +/** + * @brief Apply single parametric EQ band for woofer (Bands 6-8, left channel) + * + * Allows individual PEQ band updates without affecting other bands. + * Useful for driver-specific corrections like cone breakup modes. + * + * @param band_index PEQ band index (0-2, maps to hardware bands 6-8) + * @param peq Pointer to PEQ band settings + * @param sample_rate Current sample rate + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if band_index invalid + */ +esp_err_t tas5805m_biamp_apply_low_peq(int band_index, const tas5805m_biamp_peq_band_t *peq, uint32_t sample_rate) +{ + if (band_index < 0 || band_index >= BIAMP_PEQ_BANDS || !peq) { + return ESP_ERR_INVALID_ARG; + } + + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + int band = BIAMP_PEQ_START_BAND + band_index; + + if (peq->freq > 0 && peq->freq < fs / 2 && peq->gain != 0) { + float q = (float)peq->q_x10 / 10.0f; + float gain_db = (float)peq->gain / 2.0f; + ret = tas5805m_calc_peq((float)peq->freq, gain_db, q, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, band, &coeffs); +} + +/** + * @brief Apply single parametric EQ band for tweeter (Bands 6-8, right channel) + * + * Allows individual PEQ band updates without affecting other bands. + * Useful for driver-specific corrections like dome resonances. + * + * @param band_index PEQ band index (0-2, maps to hardware bands 6-8) + * @param peq Pointer to PEQ band settings + * @param sample_rate Current sample rate + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if band_index invalid + */ +esp_err_t tas5805m_biamp_apply_high_peq(int band_index, const tas5805m_biamp_peq_band_t *peq, uint32_t sample_rate) +{ + if (band_index < 0 || band_index >= BIAMP_PEQ_BANDS || !peq) { + return ESP_ERR_INVALID_ARG; + } + + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + int band = BIAMP_PEQ_START_BAND + band_index; + + if (peq->freq > 0 && peq->freq < fs / 2 && peq->gain != 0) { + float q = (float)peq->q_x10 / 10.0f; + float gain_db = (float)peq->gain / 2.0f; + ret = tas5805m_calc_peq((float)peq->freq, gain_db, q, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, band, &coeffs); +} + +/** + * @brief Apply baffle step compensation to Band 12 (left channel only) + * + * Applies low shelf boost to compensate for acoustic baffle diffraction step. + * The step frequency and gain are calculated based on baffle dimensions and + * speaker placement relative to room boundaries. + * + * @param width_cm Baffle width in centimeters (0 = disabled) + * @param placement Speaker placement (freestanding, near wall, corner) + * @param sample_rate Current sample rate + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_baffle_step(uint8_t width_cm, tas5805m_baffle_placement_t placement, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + + if (width_cm > 0) { + float step_freq, step_gain; + tas5805m_calc_baffle_step(width_cm, placement, &step_freq, &step_gain); + if (step_gain > 0.1f && step_freq > 0.0f && step_freq < fs / 2) { + ret = tas5805m_calc_low_shelf(step_freq, step_gain, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_LEFT, BIAMP_BAFFLE_STEP_BAND, &coeffs); +} + +/** + * @brief Apply tweeter breakup notch filter to Band 12 (right channel only) + * + * Attenuates the tweeter's breakup resonance, typically found between + * 15-25 kHz depending on dome material and size. Uses a narrow peaking + * filter with negative gain. + * + * Typical notch parameters: + * - Aluminum dome: ~20-25 kHz, Q=5-10 + * - Silk dome: ~15-18 kHz, Q=3-5 + * + * @param freq Center frequency in Hz (0 = disabled) + * @param gain_x2 Gain in 0.5dB steps (must be negative for notch effect) + * @param q_x10 Q factor x10 (e.g., 50 = Q of 5.0) + * @param sample_rate Current sample rate + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_notch(uint16_t freq, int8_t gain_x2, uint8_t q_x10, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + + if (freq > 0 && gain_x2 < 0) { + float q = (float)q_x10 / 10.0f; + float gain_db = (float)gain_x2 / 2.0f; + ret = tas5805m_calc_peq((float)freq, gain_db, q, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_NOTCH_BAND, &coeffs); +} + +/** + * @brief Apply air/brilliance high shelf to Band 13 (right channel only) + * + * Adjusts high frequency "air" or "brilliance" using a high shelf filter + * with fixed 10 kHz corner frequency. Positive gain adds sparkle/detail, + * negative gain softens the top end. + * + * @param gain_x2 Gain in 0.5dB steps (0 = disabled) + * @param sample_rate Current sample rate + * @return ESP_OK on success, error code on failure + */ +esp_err_t tas5805m_biamp_apply_air_shelf(int8_t gain_x2, uint32_t sample_rate) +{ + tas5805m_biquad_coeffs_t coeffs; + float fs = (float)validate_sample_rate(sample_rate); + esp_err_t ret; + + if (gain_x2 != 0) { + float gain_db = (float)gain_x2 / 2.0f; + ret = tas5805m_calc_high_shelf(10000.0f, gain_db, fs, &coeffs); + } else { + ret = tas5805m_calc_passthrough(&coeffs); + } + if (ret != ESP_OK) return ret; + + return write_biquad_band(TAS5805M_EQ_CHANNELS_RIGHT, BIAMP_AIR_SHELF_BAND, &coeffs); +} + +/* ============================================================================ + * Initialization + * ============================================================================ */ + +/** + * @brief Initialize bi-amp settings structure to safe defaults + * + * Sets all parameters to reasonable starting values: + * - Crossover: 2000 Hz, 24 dB/octave Linkwitz-Riley + * - Gains: 0 dB on both outputs + * - Phase: Normal (non-inverted) on both outputs + * - Subsonic filter: Disabled + * - PEQ bands: All disabled (freq=0) + * - Baffle step: Disabled + * - Time alignment: Disabled + * - Tweeter notch: Disabled + * - Air shelf: Disabled + * + * @param settings Pointer to settings structure to initialize + */ +void tas5805m_biamp_init_defaults(tas5805m_biamp_settings_t *settings) +{ + if (settings == NULL) return; + + memset(settings, 0, sizeof(tas5805m_biamp_settings_t)); + + /* Crossover configuration */ + settings->crossover_freq = TAS5805M_BIAMP_DEFAULT_XOVER_FREQ; + settings->slope = TAS5805M_BIAMP_DEFAULT_SLOPE; + settings->type = TAS5805M_BIAMP_DEFAULT_TYPE; + settings->sample_rate = TAS5805M_BIAMP_DEFAULT_SAMPLE_RATE; + settings->subsonic_freq = 0; /* Disabled */ + + /* Output levels */ + settings->low_gain = 0; + settings->low_phase_invert = 0; + settings->high_gain = 0; + settings->high_phase_invert = 0; + + /* Initialize PEQ bands to disabled with sensible Q defaults */ + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + settings->low_peq[i].freq = 0; /* Disabled */ + settings->low_peq[i].gain = 0; + settings->low_peq[i].q_x10 = 14; /* Q = 1.4 (moderate bandwidth) */ + + settings->high_peq[i].freq = 0; /* Disabled */ + settings->high_peq[i].gain = 0; + settings->high_peq[i].q_x10 = 14; /* Q = 1.4 */ + } + + /* Baffle step compensation */ + settings->baffle_width_cm = 0; /* Disabled */ + settings->baffle_placement = BAFFLE_PLACEMENT_FREESTANDING; + + /* Time alignment */ + settings->tweeter_delay_mm = 0; /* Disabled */ + + /* Tweeter breakup notch */ + settings->notch_freq = 0; /* Disabled */ + settings->notch_gain = 0; + settings->notch_q_x10 = 50; /* Q = 5.0 (narrow notch) */ + + /* Air/brilliance shelf */ + settings->air_gain = 0; /* Disabled */ +} + +#endif /* CONFIG_DAC_TAS5805M && CONFIG_DAC_TAS5805M_EQ_SUPPORT */ + +/* Stub functions when TAS5805M is enabled but EQ support is not */ +#if CONFIG_DAC_TAS5805M && !CONFIG_DAC_TAS5805M_EQ_SUPPORT + +#include +#include "esp_log.h" +static const char *TAG = "tas5805m_biamp"; + +esp_err_t tas5805m_biamp_apply(const tas5805m_biamp_settings_t *settings) { + (void)settings; + ESP_LOGW(TAG, "Biamp not available - EQ support disabled in menuconfig"); + return ESP_ERR_NOT_SUPPORTED; +} + +void tas5805m_biamp_init_defaults(tas5805m_biamp_settings_t *settings) { + if (settings) { + memset(settings, 0, sizeof(*settings)); + } +} + +#endif /* CONFIG_DAC_TAS5805M && !CONFIG_DAC_TAS5805M_EQ_SUPPORT */ diff --git a/components/tas5805m_settings/tas5805m_settings.c b/components/tas5805m_settings/tas5805m_settings.c index aa7fd89e..7d17b179 100644 --- a/components/tas5805m_settings/tas5805m_settings.c +++ b/components/tas5805m_settings/tas5805m_settings.c @@ -4,6 +4,7 @@ */ #include "tas5805m_settings.h" +#include "tas5805m_biamp.h" #if CONFIG_DAC_TAS5805M @@ -193,6 +194,7 @@ const char *tas5805m_eq_ui_mode_to_string(TAS5805M_EQ_UI_MODE m) { case TAS5805M_EQ_UI_MODE_15_BAND: return "15-band"; case TAS5805M_EQ_UI_MODE_15_BAND_BIAMP: return "15-band (bi-amp)"; case TAS5805M_EQ_UI_MODE_PRESETS: return "EQ Presets"; + case TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP: return "Advanced Bi-Amp"; default: return "Unknown"; } } @@ -767,6 +769,438 @@ esp_err_t tas5805m_settings_load_channel_gain(tas5805m_eq_chan_t ch, int *gain_d return err; } +/* ============ Advanced Bi-Amp Settings NVS Functions ============ */ + +/** Save advanced bi-amp settings to NVS */ +esp_err_t tas5805m_settings_save_biamp(const tas5805m_biamp_settings_t *settings) { + ESP_LOGD(TAG, "%s", __func__); + + if (!settings) return ESP_ERR_INVALID_ARG; + if (!tas5805m_settings_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(tas5805m_settings_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + nvs_handle_t h; + esp_err_t err = nvs_open(TAS5805M_NVS_NAMESPACE, NVS_READWRITE, &h); + if (err == ESP_OK) { + /* Crossover settings */ + err = nvs_set_u16(h, TAS5805M_NVS_KEY_BIAMP_XOVER_FREQ, settings->crossover_freq); + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_BIAMP_SLOPE, (uint8_t)settings->slope); + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_BIAMP_TYPE, (uint8_t)settings->type); + if (err == ESP_OK) err = nvs_set_u32(h, TAS5805M_NVS_KEY_BIAMP_SAMPLE_RATE, settings->sample_rate); + + /* Speaker protection */ + if (err == ESP_OK) err = nvs_set_u16(h, TAS5805M_NVS_KEY_BIAMP_SUBSONIC_FREQ, settings->subsonic_freq); + + /* Low output settings */ + if (err == ESP_OK) err = nvs_set_i8(h, TAS5805M_NVS_KEY_BIAMP_LOW_GAIN, settings->low_gain); + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_BIAMP_LOW_PHASE, settings->low_phase_invert); + + /* High output settings */ + if (err == ESP_OK) err = nvs_set_i8(h, TAS5805M_NVS_KEY_BIAMP_HIGH_GAIN, settings->high_gain); + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_BIAMP_HIGH_PHASE, settings->high_phase_invert); + + /* Per-output PEQ bands */ + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS && err == ESP_OK; i++) { + char key[20]; + snprintf(key, sizeof(key), "%s%d_f", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + err = nvs_set_u16(h, key, settings->low_peq[i].freq); + if (err == ESP_OK) { + snprintf(key, sizeof(key), "%s%d_g", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + err = nvs_set_i8(h, key, settings->low_peq[i].gain); + } + if (err == ESP_OK) { + snprintf(key, sizeof(key), "%s%d_q", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + err = nvs_set_u8(h, key, settings->low_peq[i].q_x10); + } + + if (err == ESP_OK) { + snprintf(key, sizeof(key), "%s%d_f", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + err = nvs_set_u16(h, key, settings->high_peq[i].freq); + } + if (err == ESP_OK) { + snprintf(key, sizeof(key), "%s%d_g", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + err = nvs_set_i8(h, key, settings->high_peq[i].gain); + } + if (err == ESP_OK) { + snprintf(key, sizeof(key), "%s%d_q", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + err = nvs_set_u8(h, key, settings->high_peq[i].q_x10); + } + } + + /* Baffle step compensation */ + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_BAFFLE_WIDTH, settings->baffle_width_cm); + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_BAFFLE_PLACEMENT, (uint8_t)settings->baffle_placement); + + /* Time alignment (tweeter delay) */ + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_TWEETER_DELAY, settings->tweeter_delay_mm); + + /* Tweeter breakup notch */ + if (err == ESP_OK) err = nvs_set_u16(h, TAS5805M_NVS_KEY_NOTCH_FREQ, settings->notch_freq); + if (err == ESP_OK) err = nvs_set_i8(h, TAS5805M_NVS_KEY_NOTCH_GAIN, settings->notch_gain); + if (err == ESP_OK) err = nvs_set_u8(h, TAS5805M_NVS_KEY_NOTCH_Q, settings->notch_q_x10); + + /* Air/Brilliance shelf */ + if (err == ESP_OK) err = nvs_set_i8(h, TAS5805M_NVS_KEY_AIR_GAIN, settings->air_gain); + + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + + if (err == ESP_OK) { + ESP_LOGI(TAG, "%s: Saved bi-amp settings: xover=%dHz slope=%d sr=%lu notch=%dHz air=%.1fdB", + __func__, settings->crossover_freq, settings->slope, + (unsigned long)settings->sample_rate, settings->notch_freq, settings->air_gain / 2.0); + } + } else { + ESP_LOGW(TAG, "%s: Failed to open NVS namespace '%s': %s", __func__, TAS5805M_NVS_NAMESPACE, esp_err_to_name(err)); + } + + xSemaphoreGive(tas5805m_settings_mutex); + return err; +} + +/** Load advanced bi-amp settings from NVS */ +esp_err_t tas5805m_settings_load_biamp(tas5805m_biamp_settings_t *settings) { + if (!settings) return ESP_ERR_INVALID_ARG; + if (!tas5805m_settings_mutex) return ESP_ERR_INVALID_STATE; + + /* Initialize to defaults first */ + tas5805m_biamp_init_defaults(settings); + + if (xSemaphoreTake(tas5805m_settings_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + nvs_handle_t h; + esp_err_t err = nvs_open(TAS5805M_NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + uint16_t u16val; + uint8_t u8val; + int8_t i8val; + + /* Crossover settings */ + if (nvs_get_u16(h, TAS5805M_NVS_KEY_BIAMP_XOVER_FREQ, &u16val) == ESP_OK) { + settings->crossover_freq = u16val; + } + if (nvs_get_u8(h, TAS5805M_NVS_KEY_BIAMP_SLOPE, &u8val) == ESP_OK) { + settings->slope = (tas5805m_biamp_slope_t)u8val; + } + if (nvs_get_u8(h, TAS5805M_NVS_KEY_BIAMP_TYPE, &u8val) == ESP_OK) { + settings->type = (tas5805m_biamp_type_t)u8val; + } + uint32_t u32val; + if (nvs_get_u32(h, TAS5805M_NVS_KEY_BIAMP_SAMPLE_RATE, &u32val) == ESP_OK) { + settings->sample_rate = u32val; + } + + /* Speaker protection */ + if (nvs_get_u16(h, TAS5805M_NVS_KEY_BIAMP_SUBSONIC_FREQ, &u16val) == ESP_OK) { + settings->subsonic_freq = u16val; + } + + /* Low output settings */ + if (nvs_get_i8(h, TAS5805M_NVS_KEY_BIAMP_LOW_GAIN, &i8val) == ESP_OK) { + settings->low_gain = i8val; + } + if (nvs_get_u8(h, TAS5805M_NVS_KEY_BIAMP_LOW_PHASE, &u8val) == ESP_OK) { + settings->low_phase_invert = u8val; + } + + /* High output settings */ + if (nvs_get_i8(h, TAS5805M_NVS_KEY_BIAMP_HIGH_GAIN, &i8val) == ESP_OK) { + settings->high_gain = i8val; + } + if (nvs_get_u8(h, TAS5805M_NVS_KEY_BIAMP_HIGH_PHASE, &u8val) == ESP_OK) { + settings->high_phase_invert = u8val; + } + + /* Per-output PEQ bands */ + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + char key[20]; + snprintf(key, sizeof(key), "%s%d_f", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + if (nvs_get_u16(h, key, &u16val) == ESP_OK) settings->low_peq[i].freq = u16val; + snprintf(key, sizeof(key), "%s%d_g", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + if (nvs_get_i8(h, key, &i8val) == ESP_OK) settings->low_peq[i].gain = i8val; + snprintf(key, sizeof(key), "%s%d_q", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + if (nvs_get_u8(h, key, &u8val) == ESP_OK) settings->low_peq[i].q_x10 = u8val; + + snprintf(key, sizeof(key), "%s%d_f", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + if (nvs_get_u16(h, key, &u16val) == ESP_OK) settings->high_peq[i].freq = u16val; + snprintf(key, sizeof(key), "%s%d_g", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + if (nvs_get_i8(h, key, &i8val) == ESP_OK) settings->high_peq[i].gain = i8val; + snprintf(key, sizeof(key), "%s%d_q", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + if (nvs_get_u8(h, key, &u8val) == ESP_OK) settings->high_peq[i].q_x10 = u8val; + } + + /* Baffle step compensation */ + if (nvs_get_u8(h, TAS5805M_NVS_KEY_BAFFLE_WIDTH, &u8val) == ESP_OK) { + settings->baffle_width_cm = u8val; + } + if (nvs_get_u8(h, TAS5805M_NVS_KEY_BAFFLE_PLACEMENT, &u8val) == ESP_OK) { + settings->baffle_placement = (tas5805m_baffle_placement_t)u8val; + } + + /* Time alignment (tweeter delay) */ + if (nvs_get_u8(h, TAS5805M_NVS_KEY_TWEETER_DELAY, &u8val) == ESP_OK) { + settings->tweeter_delay_mm = u8val; + } + + /* Tweeter breakup notch */ + if (nvs_get_u16(h, TAS5805M_NVS_KEY_NOTCH_FREQ, &u16val) == ESP_OK) { + settings->notch_freq = u16val; + } + if (nvs_get_i8(h, TAS5805M_NVS_KEY_NOTCH_GAIN, &i8val) == ESP_OK) { + settings->notch_gain = i8val; + } + if (nvs_get_u8(h, TAS5805M_NVS_KEY_NOTCH_Q, &u8val) == ESP_OK) { + settings->notch_q_x10 = u8val; + } + + /* Air/Brilliance shelf */ + if (nvs_get_i8(h, TAS5805M_NVS_KEY_AIR_GAIN, &i8val) == ESP_OK) { + settings->air_gain = i8val; + } + + nvs_close(h); + ESP_LOGD(TAG, "%s: Loaded bi-amp settings: xover=%dHz slope=%d sr=%lu notch=%dHz air=%.1fdB", + __func__, settings->crossover_freq, settings->slope, + (unsigned long)settings->sample_rate, settings->notch_freq, settings->air_gain / 2.0); + err = ESP_OK; + } else if (err == ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGD(TAG, "%s: No bi-amp settings in NVS, using defaults", __func__); + err = ESP_OK; + } else { + ESP_LOGW(TAG, "%s: Failed to open NVS namespace '%s': %s", __func__, TAS5805M_NVS_NAMESPACE, esp_err_to_name(err)); + } + + xSemaphoreGive(tas5805m_settings_mutex); + return err; +} + +/** Apply advanced bi-amp settings to the DAC */ +esp_err_t tas5805m_settings_apply_biamp(const tas5805m_biamp_settings_t *settings) { + if (!settings) return ESP_ERR_INVALID_ARG; + + ESP_LOGI(TAG, "%s: Applying bi-amp settings", __func__); + return tas5805m_biamp_apply(settings); +} + +/* ============ Loudness Compensation Functions ============ */ + +/** Cached loudness settings (loaded at init) */ +static tas5805m_loudness_settings_t s_loudness_settings; +static int s_current_volume = 50; /* Track current volume for zone detection */ + +/** Initialize loudness settings to defaults */ +void tas5805m_loudness_init_defaults(tas5805m_loudness_settings_t *settings) { + if (!settings) return; + + settings->enabled = 0; /* Disabled by default */ + + /* Default thresholds: 20%, 40%, 60%, 80% */ + settings->thresholds[0] = 20; + settings->thresholds[1] = 40; + settings->thresholds[2] = 60; + settings->thresholds[3] = 80; + + /* Default bass boost: more at lower volumes */ + settings->bass_boost[0] = 6; /* Zone 0: 0-20% volume */ + settings->bass_boost[1] = 4; /* Zone 1: 20-40% */ + settings->bass_boost[2] = 2; /* Zone 2: 40-60% */ + settings->bass_boost[3] = 1; /* Zone 3: 60-80% */ + settings->bass_boost[4] = 0; /* Zone 4: 80-100% */ + + /* Default treble boost */ + settings->treble_boost[0] = 4; + settings->treble_boost[1] = 3; + settings->treble_boost[2] = 2; + settings->treble_boost[3] = 1; + settings->treble_boost[4] = 0; +} + +/** Save loudness settings to NVS */ +esp_err_t tas5805m_settings_save_loudness(const tas5805m_loudness_settings_t *settings) { + if (!settings) return ESP_ERR_INVALID_ARG; + if (!tas5805m_settings_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(tas5805m_settings_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + nvs_handle_t h; + esp_err_t err = nvs_open(TAS5805M_NVS_NAMESPACE, NVS_READWRITE, &h); + if (err == ESP_OK) { + err = nvs_set_u8(h, TAS5805M_NVS_KEY_LOUDNESS_ENABLED, settings->enabled); + if (err == ESP_OK) { + err = nvs_set_blob(h, TAS5805M_NVS_KEY_LOUDNESS_THRESH, + settings->thresholds, sizeof(settings->thresholds)); + } + if (err == ESP_OK) { + err = nvs_set_blob(h, TAS5805M_NVS_KEY_LOUDNESS_BASS, + settings->bass_boost, sizeof(settings->bass_boost)); + } + if (err == ESP_OK) { + err = nvs_set_blob(h, TAS5805M_NVS_KEY_LOUDNESS_TREBLE, + settings->treble_boost, sizeof(settings->treble_boost)); + } + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + + if (err == ESP_OK) { + /* Update cached settings */ + memcpy(&s_loudness_settings, settings, sizeof(s_loudness_settings)); + ESP_LOGI(TAG, "%s: Saved loudness settings (enabled=%d)", __func__, settings->enabled); + } + } + + xSemaphoreGive(tas5805m_settings_mutex); + return err; +} + +/** Load loudness settings from NVS */ +esp_err_t tas5805m_settings_load_loudness(tas5805m_loudness_settings_t *settings) { + if (!settings) return ESP_ERR_INVALID_ARG; + if (!tas5805m_settings_mutex) return ESP_ERR_INVALID_STATE; + + /* Initialize to defaults first */ + tas5805m_loudness_init_defaults(settings); + + if (xSemaphoreTake(tas5805m_settings_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + nvs_handle_t h; + esp_err_t err = nvs_open(TAS5805M_NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + uint8_t enabled = 0; + if (nvs_get_u8(h, TAS5805M_NVS_KEY_LOUDNESS_ENABLED, &enabled) == ESP_OK) { + settings->enabled = enabled; + } + + size_t len = sizeof(settings->thresholds); + nvs_get_blob(h, TAS5805M_NVS_KEY_LOUDNESS_THRESH, settings->thresholds, &len); + + len = sizeof(settings->bass_boost); + nvs_get_blob(h, TAS5805M_NVS_KEY_LOUDNESS_BASS, settings->bass_boost, &len); + + len = sizeof(settings->treble_boost); + nvs_get_blob(h, TAS5805M_NVS_KEY_LOUDNESS_TREBLE, settings->treble_boost, &len); + + nvs_close(h); + ESP_LOGD(TAG, "%s: Loaded loudness settings (enabled=%d)", __func__, settings->enabled); + } else if (err == ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGD(TAG, "%s: No loudness settings in NVS, using defaults", __func__); + err = ESP_OK; + } + + /* Update cached settings */ + memcpy(&s_loudness_settings, settings, sizeof(s_loudness_settings)); + + xSemaphoreGive(tas5805m_settings_mutex); + return err; +} + +/** Get current loudness zone (0-4) for a given volume (0-100) */ +int tas5805m_loudness_get_zone(int volume) { + if (volume < 0) volume = 0; + if (volume > 100) volume = 100; + + for (int i = 0; i < TAS5805M_LOUDNESS_ZONES - 1; i++) { + if (volume < s_loudness_settings.thresholds[i]) { + return i; + } + } + return TAS5805M_LOUDNESS_ZONES - 1; /* Highest zone */ +} + +/** Apply loudness compensation based on current volume */ +esp_err_t tas5805m_loudness_apply(int volume) { +#if CONFIG_DAC_TAS5805M_EQ_SUPPORT + /* Get current sample rate from bi-amp settings or use default */ + float fs = 48000.0f; /* Default sample rate */ + tas5805m_biamp_settings_t biamp; + if (tas5805m_settings_load_biamp(&biamp) == ESP_OK && biamp.sample_rate > 0) { + fs = (float)biamp.sample_rate; + } + + tas5805m_biquad_coeffs_t coeffs; + esp_err_t ret; + + if (!s_loudness_settings.enabled) { + /* Loudness disabled: reset bands 13 and 14 to passthrough (0dB, flat response) */ + ESP_LOGI(TAG, "%s: Loudness disabled, resetting bands to passthrough", __func__); + + /* Reset bass band (13) to passthrough on LEFT channel */ + ret = tas5805m_calc_low_shelf(200.0f, 0.0f, fs, &coeffs); + if (ret == ESP_OK) { + ret = tas5805m_write_biquad_coefficients(TAS5805M_EQ_CHANNELS_LEFT, + TAS5805M_LOUDNESS_BASS_BAND, + coeffs.b0, coeffs.b1, coeffs.b2, + coeffs.a1, coeffs.a2); + } + if (ret != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to reset bass band to passthrough", __func__); + } + + /* Reset treble band (14) to passthrough on RIGHT channel */ + ret = tas5805m_calc_high_shelf(4000.0f, 0.0f, fs, &coeffs); + if (ret == ESP_OK) { + ret = tas5805m_write_biquad_coefficients(TAS5805M_EQ_CHANNELS_RIGHT, + TAS5805M_LOUDNESS_TREBLE_BAND, + coeffs.b0, coeffs.b1, coeffs.b2, + coeffs.a1, coeffs.a2); + } + if (ret != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to reset treble band to passthrough", __func__); + } + + return ESP_OK; + } + + /* Loudness enabled: apply boost based on volume zone */ + s_current_volume = volume; + int zone = tas5805m_loudness_get_zone(volume); + int8_t bass_db = s_loudness_settings.bass_boost[zone]; + int8_t treble_db = s_loudness_settings.treble_boost[zone]; + + ESP_LOGI(TAG, "%s: Volume=%d%% Zone=%d Bass=%+ddB Treble=%+ddB", + __func__, volume, zone, bass_db, treble_db); + + /* Apply low shelf for bass boost (200 Hz) - LEFT channel only (woofer in bi-amp) */ + ret = tas5805m_calc_low_shelf(200.0f, (float)bass_db, fs, &coeffs); + if (ret == ESP_OK) { + ret = tas5805m_write_biquad_coefficients(TAS5805M_EQ_CHANNELS_LEFT, + TAS5805M_LOUDNESS_BASS_BAND, + coeffs.b0, coeffs.b1, coeffs.b2, + coeffs.a1, coeffs.a2); + } + if (ret != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to apply bass shelf to woofer", __func__); + } + + /* Apply high shelf for treble boost (4000 Hz) - RIGHT channel only (tweeter in bi-amp) */ + ret = tas5805m_calc_high_shelf(4000.0f, (float)treble_db, fs, &coeffs); + if (ret == ESP_OK) { + ret = tas5805m_write_biquad_coefficients(TAS5805M_EQ_CHANNELS_RIGHT, + TAS5805M_LOUDNESS_TREBLE_BAND, + coeffs.b0, coeffs.b1, coeffs.b2, + coeffs.a1, coeffs.a2); + } + if (ret != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to apply treble shelf to tweeter", __func__); + } + + return ESP_OK; +#else + /* EQ support not enabled - loudness compensation unavailable */ + (void)volume; + ESP_LOGD(TAG, "%s: EQ support disabled, loudness compensation not available", __func__); + return ESP_OK; +#endif /* CONFIG_DAC_TAS5805M_EQ_SUPPORT */ +} + /** Load EQ mode from NVS */ esp_err_t tas5805m_settings_load_eq_mode(tas5805m_eq_mode_t *mode) { if (!mode) return ESP_ERR_INVALID_ARG; @@ -1176,6 +1610,7 @@ esp_err_t tas5805m_settings_set_from_json(const char *json_in) { case TAS5805M_EQ_UI_MODE_15_BAND: drv = TAS5805M_EQ_MODE_ON; break; case TAS5805M_EQ_UI_MODE_15_BAND_BIAMP: drv = TAS5805M_EQ_MODE_BIAMP; break; case TAS5805M_EQ_UI_MODE_PRESETS: drv = TAS5805M_EQ_MODE_BIAMP; break; + case TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP: drv = TAS5805M_EQ_MODE_BIAMP; break; default: drv = TAS5805M_EQ_MODE_OFF; break; } @@ -2267,8 +2702,47 @@ esp_err_t tas5805m_settings_get_eq_schema_json(char *json_out, size_t max_len) { cJSON *groups = cJSON_CreateArray(); - // ===== Channel Gain Group (always visible, first in EQ schema) ===== + /* Load UI mode early so it can be used to conditionally include groups */ + TAS5805M_EQ_UI_MODE ui_mode = TAS5805M_EQ_UI_MODE_OFF; + tas5805m_settings_load_eq_ui_mode(&ui_mode); + +#if defined(CONFIG_DAC_TAS5805M_EQ_SUPPORT) + // ===== EQ Mode Group (first in schema as requested) ===== { + TAS5805M_EQ_MODE eq_mode_val = TAS5805M_EQ_MODE_OFF; + tas5805m_get_eq_mode(&eq_mode_val); + + cJSON *eq_mode_group = cJSON_CreateObject(); + cJSON_AddStringToObject(eq_mode_group, "name", "EQ Mode"); + cJSON_AddStringToObject(eq_mode_group, "description", "Equalizer operation mode"); + + cJSON *eq_mode_params = cJSON_CreateArray(); + + cJSON *eq_ui_mode_param = cJSON_CreateObject(); + cJSON_AddStringToObject(eq_ui_mode_param, "key", "eq_ui_mode"); + cJSON_AddStringToObject(eq_ui_mode_param, "name", "EQ Mode"); + cJSON_AddStringToObject(eq_ui_mode_param, "type", "enum"); + + cJSON_AddNumberToObject(eq_ui_mode_param, "current", (int)ui_mode); + + cJSON *eq_ui_mode_values = cJSON_CreateArray(); + const char *ui_mode_names[] = {"Off", "15-Band", "15-Band Bi-Amp", "Presets", "Advanced Bi-Amp"}; + for (int i = 0; i <= TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP; ++i) { + cJSON *val = cJSON_CreateObject(); + cJSON_AddNumberToObject(val, "value", i); + cJSON_AddStringToObject(val, "name", ui_mode_names[i]); + cJSON_AddItemToArray(eq_ui_mode_values, val); + } + cJSON_AddItemToObject(eq_ui_mode_param, "values", eq_ui_mode_values); + cJSON_AddItemToArray(eq_mode_params, eq_ui_mode_param); + + cJSON_AddItemToObject(eq_mode_group, "parameters", eq_mode_params); + cJSON_AddItemToArray(groups, eq_mode_group); + } +#endif + + // ===== Channel Gain Group (hidden in Advanced Bi-Amp mode - uses Low/High gains instead) ===== + if (ui_mode != TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP) { cJSON *ch_group = cJSON_CreateObject(); cJSON_AddStringToObject(ch_group, "name", "Channel Gain"); cJSON_AddStringToObject(ch_group, "description", "Per-channel mixer gain control"); @@ -2572,6 +3046,487 @@ esp_err_t tas5805m_settings_get_eq_schema_json(char *json_out, size_t max_len) { cJSON_AddItemToArray(groups, eq_bands_right); #endif + /* ===== Advanced Bi-Amp Crossover Group (only shown in Advanced Bi-Amp mode) ===== */ + if (ui_mode == TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP) { + tas5805m_biamp_settings_t biamp; + tas5805m_settings_load_biamp(&biamp); + tas5805m_loudness_settings_t loudness; + tas5805m_settings_load_loudness(&loudness); + + cJSON *biamp_group = cJSON_CreateObject(); + cJSON_AddStringToObject(biamp_group, "name", "Advanced Bi-Amp Crossover"); + cJSON_AddStringToObject(biamp_group, "description", "Configure active crossover with per-output PEQ and gain"); + cJSON_AddStringToObject(biamp_group, "layout", "biamp-crossover"); + + cJSON *sections = cJSON_CreateArray(); + + /* === Section 1: Crossover Settings === */ + { + cJSON *xover_section = cJSON_CreateObject(); + cJSON_AddStringToObject(xover_section, "name", "Crossover Settings"); + cJSON_AddStringToObject(xover_section, "layout", "form"); + cJSON *xover_params = cJSON_CreateArray(); + + cJSON *xover_freq = cJSON_CreateObject(); + cJSON_AddStringToObject(xover_freq, "key", "biamp_xover_freq"); + cJSON_AddStringToObject(xover_freq, "name", "Frequency"); + cJSON_AddStringToObject(xover_freq, "type", "range"); + cJSON_AddStringToObject(xover_freq, "unit", "Hz"); + cJSON_AddNumberToObject(xover_freq, "min", 20); + cJSON_AddNumberToObject(xover_freq, "max", 20000); + cJSON_AddNumberToObject(xover_freq, "step", 1); + cJSON_AddNumberToObject(xover_freq, "current", biamp.crossover_freq); + cJSON_AddItemToArray(xover_params, xover_freq); + + cJSON *slope = cJSON_CreateObject(); + cJSON_AddStringToObject(slope, "key", "biamp_slope"); + cJSON_AddStringToObject(slope, "name", "Slope"); + cJSON_AddStringToObject(slope, "type", "enum"); + cJSON_AddNumberToObject(slope, "current", (int)biamp.slope); + cJSON *slope_vals = cJSON_CreateArray(); + cJSON *sv; + sv = cJSON_CreateObject(); cJSON_AddNumberToObject(sv, "value", BIAMP_SLOPE_12DB); cJSON_AddStringToObject(sv, "name", "12 dB/oct"); cJSON_AddItemToArray(slope_vals, sv); + sv = cJSON_CreateObject(); cJSON_AddNumberToObject(sv, "value", BIAMP_SLOPE_24DB); cJSON_AddStringToObject(sv, "name", "24 dB/oct (LR)"); cJSON_AddItemToArray(slope_vals, sv); + sv = cJSON_CreateObject(); cJSON_AddNumberToObject(sv, "value", BIAMP_SLOPE_48DB); cJSON_AddStringToObject(sv, "name", "48 dB/oct"); cJSON_AddItemToArray(slope_vals, sv); + cJSON_AddItemToObject(slope, "values", slope_vals); + cJSON_AddItemToArray(xover_params, slope); + + /* Filter type selector removed: cascading identical BW2 stages + * produces Linkwitz-Riley alignment, which is the industry standard + * for audio crossovers. The type field is kept in NVS/presets for + * backward compatibility but is not exposed in the UI. */ + + cJSON *sr = cJSON_CreateObject(); + cJSON_AddStringToObject(sr, "key", "biamp_sample_rate"); + cJSON_AddStringToObject(sr, "name", "Sample Rate"); + cJSON_AddStringToObject(sr, "type", "enum"); + cJSON_AddNumberToObject(sr, "current", (int)biamp.sample_rate); + cJSON *sr_vals = cJSON_CreateArray(); + cJSON *srv; + srv = cJSON_CreateObject(); cJSON_AddNumberToObject(srv, "value", BIAMP_SAMPLE_RATE_44100); cJSON_AddStringToObject(srv, "name", "44.1 kHz"); cJSON_AddItemToArray(sr_vals, srv); + srv = cJSON_CreateObject(); cJSON_AddNumberToObject(srv, "value", BIAMP_SAMPLE_RATE_48000); cJSON_AddStringToObject(srv, "name", "48 kHz"); cJSON_AddItemToArray(sr_vals, srv); + srv = cJSON_CreateObject(); cJSON_AddNumberToObject(srv, "value", BIAMP_SAMPLE_RATE_88200); cJSON_AddStringToObject(srv, "name", "88.2 kHz"); cJSON_AddItemToArray(sr_vals, srv); + srv = cJSON_CreateObject(); cJSON_AddNumberToObject(srv, "value", BIAMP_SAMPLE_RATE_96000); cJSON_AddStringToObject(srv, "name", "96 kHz"); cJSON_AddItemToArray(sr_vals, srv); + cJSON_AddItemToObject(sr, "values", sr_vals); + cJSON_AddItemToArray(xover_params, sr); + + cJSON_AddItemToObject(xover_section, "parameters", xover_params); + cJSON_AddItemToArray(sections, xover_section); + } + + /* === Section 2: Low/High Output Columns === */ + { + cJSON *columns_section = cJSON_CreateObject(); + cJSON *columns = cJSON_CreateArray(); + + /* Low Output (Woofer) Column */ + cJSON *low_col = cJSON_CreateObject(); + cJSON_AddStringToObject(low_col, "name", "Low Output (Woofer)"); + cJSON_AddStringToObject(low_col, "layout", "output-channel"); + cJSON *low_params = cJSON_CreateArray(); + + /* Gain slider */ + cJSON *low_gain = cJSON_CreateObject(); + cJSON_AddStringToObject(low_gain, "key", "biamp_low_gain"); + cJSON_AddStringToObject(low_gain, "label", "Gain"); + cJSON_AddStringToObject(low_gain, "name", "Output Gain"); + cJSON_AddStringToObject(low_gain, "type", "range"); + cJSON_AddStringToObject(low_gain, "unit", "dB"); + cJSON_AddNumberToObject(low_gain, "min", -24); + cJSON_AddNumberToObject(low_gain, "max", 24); + cJSON_AddNumberToObject(low_gain, "step", 0.5); + cJSON_AddNumberToObject(low_gain, "decimals", 1); + cJSON_AddNumberToObject(low_gain, "current", biamp.low_gain / 2.0); + cJSON_AddItemToArray(low_params, low_gain); + + /* Subsonic HPF slider (woofer only) */ + cJSON *subsonic = cJSON_CreateObject(); + cJSON_AddStringToObject(subsonic, "key", "biamp_subsonic_freq"); + cJSON_AddStringToObject(subsonic, "label", "Subsonic"); + cJSON_AddStringToObject(subsonic, "name", "Subsonic HPF"); + cJSON_AddStringToObject(subsonic, "type", "range"); + cJSON_AddStringToObject(subsonic, "unit", "Hz"); + cJSON_AddNumberToObject(subsonic, "min", 0); + cJSON_AddNumberToObject(subsonic, "max", 80); + cJSON_AddNumberToObject(subsonic, "step", 5); + cJSON_AddNumberToObject(subsonic, "current", biamp.subsonic_freq); + cJSON_AddItemToArray(low_params, subsonic); + + /* Baffle Step Compensation subgroup (woofer only) */ + { + cJSON *baffle_group = cJSON_CreateObject(); + cJSON_AddStringToObject(baffle_group, "name", "Baffle Step"); + cJSON_AddStringToObject(baffle_group, "type", "subgroup"); + cJSON *baffle_params = cJSON_CreateArray(); + + /* Baffle width (0=disabled, 5-50cm) */ + cJSON *baffle_width = cJSON_CreateObject(); + cJSON_AddStringToObject(baffle_width, "key", "biamp_baffle_width"); + cJSON_AddStringToObject(baffle_width, "name", "Baffle Width"); + cJSON_AddStringToObject(baffle_width, "type", "range"); + cJSON_AddStringToObject(baffle_width, "unit", "cm"); + cJSON_AddNumberToObject(baffle_width, "min", 0); + cJSON_AddNumberToObject(baffle_width, "max", 50); + cJSON_AddNumberToObject(baffle_width, "step", 1); + cJSON_AddNumberToObject(baffle_width, "current", biamp.baffle_width_cm); + cJSON_AddItemToArray(baffle_params, baffle_width); + + /* Placement type */ + cJSON *placement = cJSON_CreateObject(); + cJSON_AddStringToObject(placement, "key", "biamp_baffle_placement"); + cJSON_AddStringToObject(placement, "name", "Placement"); + cJSON_AddStringToObject(placement, "type", "enum"); + cJSON_AddNumberToObject(placement, "current", (int)biamp.baffle_placement); + cJSON *place_vals = cJSON_CreateArray(); + cJSON *pv; + pv = cJSON_CreateObject(); cJSON_AddNumberToObject(pv, "value", BAFFLE_PLACEMENT_FREESTANDING); cJSON_AddStringToObject(pv, "name", "Freestanding (+6dB)"); cJSON_AddItemToArray(place_vals, pv); + pv = cJSON_CreateObject(); cJSON_AddNumberToObject(pv, "value", BAFFLE_PLACEMENT_NEAR_WALL); cJSON_AddStringToObject(pv, "name", "Near Wall (+3dB)"); cJSON_AddItemToArray(place_vals, pv); + pv = cJSON_CreateObject(); cJSON_AddNumberToObject(pv, "value", BAFFLE_PLACEMENT_CORNER); cJSON_AddStringToObject(pv, "name", "Corner (0dB)"); cJSON_AddItemToArray(place_vals, pv); + cJSON_AddItemToObject(placement, "values", place_vals); + cJSON_AddItemToArray(baffle_params, placement); + + cJSON_AddItemToObject(baffle_group, "parameters", baffle_params); + cJSON_AddItemToArray(low_params, baffle_group); + } + + /* PEQ bands as subgroups */ + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + char key[32], label[32]; + cJSON *peq_group = cJSON_CreateObject(); + snprintf(label, sizeof(label), "PEQ %d", i + 1); + cJSON_AddStringToObject(peq_group, "name", label); + cJSON_AddStringToObject(peq_group, "type", "peq-subgroup"); + cJSON *peq_params = cJSON_CreateArray(); + + /* Frequency */ + snprintf(key, sizeof(key), "biamp_low_peq%d_freq", i); + cJSON *freq = cJSON_CreateObject(); + cJSON_AddStringToObject(freq, "key", key); + cJSON_AddStringToObject(freq, "name", "Freq"); + cJSON_AddStringToObject(freq, "type", "range"); + cJSON_AddStringToObject(freq, "unit", "Hz"); + cJSON_AddNumberToObject(freq, "min", 20); + cJSON_AddNumberToObject(freq, "max", 20000); + cJSON_AddNumberToObject(freq, "step", 1); + cJSON_AddNumberToObject(freq, "current", biamp.low_peq[i].freq); + cJSON_AddItemToArray(peq_params, freq); + + /* Gain (stored as x2 for 0.5 dB resolution) */ + snprintf(key, sizeof(key), "biamp_low_peq%d_gain", i); + cJSON *gain = cJSON_CreateObject(); + cJSON_AddStringToObject(gain, "key", key); + cJSON_AddStringToObject(gain, "name", "Gain"); + cJSON_AddStringToObject(gain, "type", "range"); + cJSON_AddStringToObject(gain, "unit", "dB"); + cJSON_AddNumberToObject(gain, "min", -15); + cJSON_AddNumberToObject(gain, "max", 15); + cJSON_AddNumberToObject(gain, "step", 0.5); + cJSON_AddNumberToObject(gain, "decimals", 1); + cJSON_AddNumberToObject(gain, "current", biamp.low_peq[i].gain / 2.0); + cJSON_AddItemToArray(peq_params, gain); + + /* Q factor (stored as q_x10, display as float) */ + snprintf(key, sizeof(key), "biamp_low_peq%d_q", i); + cJSON *q = cJSON_CreateObject(); + cJSON_AddStringToObject(q, "key", key); + cJSON_AddStringToObject(q, "name", "Q"); + cJSON_AddStringToObject(q, "type", "range"); + cJSON_AddNumberToObject(q, "min", 0.5); + cJSON_AddNumberToObject(q, "max", 10.0); + cJSON_AddNumberToObject(q, "step", 0.1); + cJSON_AddNumberToObject(q, "decimals", 1); + cJSON_AddNumberToObject(q, "current", biamp.low_peq[i].q_x10 / 10.0); + cJSON_AddItemToArray(peq_params, q); + + cJSON_AddItemToObject(peq_group, "parameters", peq_params); + cJSON_AddItemToArray(low_params, peq_group); + } + + /* Phase at the end */ + cJSON *low_phase = cJSON_CreateObject(); + cJSON_AddStringToObject(low_phase, "key", "biamp_low_phase"); + cJSON_AddStringToObject(low_phase, "name", "Phase"); + cJSON_AddStringToObject(low_phase, "type", "radio"); + cJSON_AddNumberToObject(low_phase, "current", biamp.low_phase_invert); + cJSON *lp_vals = cJSON_CreateArray(); + cJSON *lpv; + lpv = cJSON_CreateObject(); cJSON_AddNumberToObject(lpv, "value", 0); cJSON_AddStringToObject(lpv, "name", "Normal"); cJSON_AddItemToArray(lp_vals, lpv); + lpv = cJSON_CreateObject(); cJSON_AddNumberToObject(lpv, "value", 1); cJSON_AddStringToObject(lpv, "name", "Invert"); cJSON_AddItemToArray(lp_vals, lpv); + cJSON_AddItemToObject(low_phase, "values", lp_vals); + cJSON_AddItemToArray(low_params, low_phase); + + cJSON_AddItemToObject(low_col, "parameters", low_params); + cJSON_AddItemToArray(columns, low_col); + + /* High Output (Tweeter) Column */ + cJSON *high_col = cJSON_CreateObject(); + cJSON_AddStringToObject(high_col, "name", "High Output (Tweeter)"); + cJSON_AddStringToObject(high_col, "layout", "output-channel"); + cJSON *high_params = cJSON_CreateArray(); + + /* Gain slider */ + cJSON *high_gain = cJSON_CreateObject(); + cJSON_AddStringToObject(high_gain, "key", "biamp_high_gain"); + cJSON_AddStringToObject(high_gain, "label", "Gain"); + cJSON_AddStringToObject(high_gain, "name", "Output Gain"); + cJSON_AddStringToObject(high_gain, "type", "range"); + cJSON_AddStringToObject(high_gain, "unit", "dB"); + cJSON_AddNumberToObject(high_gain, "min", -24); + cJSON_AddNumberToObject(high_gain, "max", 24); + cJSON_AddNumberToObject(high_gain, "step", 0.5); + cJSON_AddNumberToObject(high_gain, "decimals", 1); + cJSON_AddNumberToObject(high_gain, "current", biamp.high_gain / 2.0); + cJSON_AddItemToArray(high_params, high_gain); + + /* Tweeter delay slider for time alignment */ + cJSON *twt_delay = cJSON_CreateObject(); + cJSON_AddStringToObject(twt_delay, "key", "biamp_tweeter_delay"); + cJSON_AddStringToObject(twt_delay, "label", "Delay"); + cJSON_AddStringToObject(twt_delay, "name", "Tweeter Delay"); + cJSON_AddStringToObject(twt_delay, "description", "Delays the tweeter to align with the woofer acoustic center. Set to the physical offset between drivers."); + cJSON_AddStringToObject(twt_delay, "type", "range"); + cJSON_AddStringToObject(twt_delay, "unit", "mm"); + cJSON_AddNumberToObject(twt_delay, "min", 0); + cJSON_AddNumberToObject(twt_delay, "max", 50); + cJSON_AddNumberToObject(twt_delay, "step", 1); + cJSON_AddNumberToObject(twt_delay, "current", biamp.tweeter_delay_mm); + cJSON_AddItemToArray(high_params, twt_delay); + + /* Breakup Notch subgroup */ + { + cJSON *notch_group = cJSON_CreateObject(); + cJSON_AddStringToObject(notch_group, "name", "Breakup Notch"); + cJSON_AddStringToObject(notch_group, "type", "subgroup"); + cJSON_AddStringToObject(notch_group, "description", "Tames the resonance peak at the tweeter's upper frequency limit"); + cJSON *notch_params = cJSON_CreateArray(); + + /* Notch frequency */ + cJSON *notch_freq = cJSON_CreateObject(); + cJSON_AddStringToObject(notch_freq, "key", "biamp_notch_freq"); + cJSON_AddStringToObject(notch_freq, "name", "Frequency"); + cJSON_AddStringToObject(notch_freq, "type", "range"); + cJSON_AddStringToObject(notch_freq, "unit", "Hz"); + cJSON_AddNumberToObject(notch_freq, "min", 0); + cJSON_AddNumberToObject(notch_freq, "max", 25000); + cJSON_AddNumberToObject(notch_freq, "step", 100); + cJSON_AddNumberToObject(notch_freq, "current", biamp.notch_freq); + cJSON_AddItemToArray(notch_params, notch_freq); + + /* Notch depth (gain, negative only) */ + cJSON *notch_gain = cJSON_CreateObject(); + cJSON_AddStringToObject(notch_gain, "key", "biamp_notch_gain"); + cJSON_AddStringToObject(notch_gain, "name", "Depth"); + cJSON_AddStringToObject(notch_gain, "type", "range"); + cJSON_AddStringToObject(notch_gain, "unit", "dB"); + cJSON_AddNumberToObject(notch_gain, "min", -12); + cJSON_AddNumberToObject(notch_gain, "max", 0); + cJSON_AddNumberToObject(notch_gain, "step", 0.5); + cJSON_AddNumberToObject(notch_gain, "decimals", 1); + cJSON_AddNumberToObject(notch_gain, "current", biamp.notch_gain / 2.0); + cJSON_AddItemToArray(notch_params, notch_gain); + + /* Notch Q */ + cJSON *notch_q = cJSON_CreateObject(); + cJSON_AddStringToObject(notch_q, "key", "biamp_notch_q"); + cJSON_AddStringToObject(notch_q, "name", "Q"); + cJSON_AddStringToObject(notch_q, "type", "range"); + cJSON_AddNumberToObject(notch_q, "min", 2.0); + cJSON_AddNumberToObject(notch_q, "max", 10.0); + cJSON_AddNumberToObject(notch_q, "step", 0.1); + cJSON_AddNumberToObject(notch_q, "decimals", 1); + cJSON_AddNumberToObject(notch_q, "current", biamp.notch_q_x10 / 10.0); + cJSON_AddItemToArray(notch_params, notch_q); + + cJSON_AddItemToObject(notch_group, "parameters", notch_params); + cJSON_AddItemToArray(high_params, notch_group); + } + + /* PEQ bands as subgroups */ + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + char key[32], label[32]; + cJSON *peq_group = cJSON_CreateObject(); + snprintf(label, sizeof(label), "PEQ %d", i + 1); + cJSON_AddStringToObject(peq_group, "name", label); + cJSON_AddStringToObject(peq_group, "type", "peq-subgroup"); + cJSON *peq_params = cJSON_CreateArray(); + + /* Frequency */ + snprintf(key, sizeof(key), "biamp_high_peq%d_freq", i); + cJSON *freq = cJSON_CreateObject(); + cJSON_AddStringToObject(freq, "key", key); + cJSON_AddStringToObject(freq, "name", "Freq"); + cJSON_AddStringToObject(freq, "type", "range"); + cJSON_AddStringToObject(freq, "unit", "Hz"); + cJSON_AddNumberToObject(freq, "min", 20); + cJSON_AddNumberToObject(freq, "max", 20000); + cJSON_AddNumberToObject(freq, "step", 1); + cJSON_AddNumberToObject(freq, "current", biamp.high_peq[i].freq); + cJSON_AddItemToArray(peq_params, freq); + + /* Gain (stored as x2 for 0.5 dB resolution) */ + snprintf(key, sizeof(key), "biamp_high_peq%d_gain", i); + cJSON *gain = cJSON_CreateObject(); + cJSON_AddStringToObject(gain, "key", key); + cJSON_AddStringToObject(gain, "name", "Gain"); + cJSON_AddStringToObject(gain, "type", "range"); + cJSON_AddStringToObject(gain, "unit", "dB"); + cJSON_AddNumberToObject(gain, "min", -15); + cJSON_AddNumberToObject(gain, "max", 15); + cJSON_AddNumberToObject(gain, "step", 0.5); + cJSON_AddNumberToObject(gain, "decimals", 1); + cJSON_AddNumberToObject(gain, "current", biamp.high_peq[i].gain / 2.0); + cJSON_AddItemToArray(peq_params, gain); + + /* Q factor (stored as q_x10, display as float) */ + snprintf(key, sizeof(key), "biamp_high_peq%d_q", i); + cJSON *q = cJSON_CreateObject(); + cJSON_AddStringToObject(q, "key", key); + cJSON_AddStringToObject(q, "name", "Q"); + cJSON_AddStringToObject(q, "type", "range"); + cJSON_AddNumberToObject(q, "min", 0.5); + cJSON_AddNumberToObject(q, "max", 10.0); + cJSON_AddNumberToObject(q, "step", 0.1); + cJSON_AddNumberToObject(q, "decimals", 1); + cJSON_AddNumberToObject(q, "current", biamp.high_peq[i].q_x10 / 10.0); + cJSON_AddItemToArray(peq_params, q); + + cJSON_AddItemToObject(peq_group, "parameters", peq_params); + cJSON_AddItemToArray(high_params, peq_group); + } + + /* Phase at the end */ + cJSON *high_phase = cJSON_CreateObject(); + cJSON_AddStringToObject(high_phase, "key", "biamp_high_phase"); + cJSON_AddStringToObject(high_phase, "name", "Phase"); + cJSON_AddStringToObject(high_phase, "type", "radio"); + cJSON_AddNumberToObject(high_phase, "current", biamp.high_phase_invert); + cJSON *hp_vals = cJSON_CreateArray(); + cJSON *hpv; + hpv = cJSON_CreateObject(); cJSON_AddNumberToObject(hpv, "value", 0); cJSON_AddStringToObject(hpv, "name", "Normal"); cJSON_AddItemToArray(hp_vals, hpv); + hpv = cJSON_CreateObject(); cJSON_AddNumberToObject(hpv, "value", 1); cJSON_AddStringToObject(hpv, "name", "Invert"); cJSON_AddItemToArray(hp_vals, hpv); + cJSON_AddItemToObject(high_phase, "values", hp_vals); + cJSON_AddItemToArray(high_params, high_phase); + + /* Air/Brilliance shelf slider (after phase) */ + cJSON *air_gain = cJSON_CreateObject(); + cJSON_AddStringToObject(air_gain, "key", "biamp_air_gain"); + cJSON_AddStringToObject(air_gain, "label", "Air"); + cJSON_AddStringToObject(air_gain, "name", "Air / Brilliance"); + cJSON_AddStringToObject(air_gain, "description", "High shelf at 10kHz for treble sparkle and air"); + cJSON_AddStringToObject(air_gain, "type", "range"); + cJSON_AddStringToObject(air_gain, "unit", "dB"); + cJSON_AddNumberToObject(air_gain, "min", -6); + cJSON_AddNumberToObject(air_gain, "max", 6); + cJSON_AddNumberToObject(air_gain, "step", 0.5); + cJSON_AddNumberToObject(air_gain, "decimals", 1); + cJSON_AddNumberToObject(air_gain, "current", biamp.air_gain / 2.0); + cJSON_AddItemToArray(high_params, air_gain); + + cJSON_AddItemToObject(high_col, "parameters", high_params); + cJSON_AddItemToArray(columns, high_col); + + cJSON_AddItemToObject(columns_section, "columns", columns); + cJSON_AddItemToArray(sections, columns_section); + } + + /* === Section 3: Loudness Compensation === */ + { + cJSON *loud_section = cJSON_CreateObject(); + cJSON_AddStringToObject(loud_section, "name", "Loudness Compensation"); + cJSON_AddStringToObject(loud_section, "layout", "loudness"); + cJSON *loud_params = cJSON_CreateArray(); + + /* Enabled toggle (always visible) */ + cJSON *loud_en = cJSON_CreateObject(); + cJSON_AddStringToObject(loud_en, "key", "loudness_enabled"); + cJSON_AddStringToObject(loud_en, "name", "Enable"); + cJSON_AddStringToObject(loud_en, "type", "enum"); + cJSON_AddNumberToObject(loud_en, "current", loudness.enabled); + cJSON *loud_en_vals = cJSON_CreateArray(); + cJSON *lev; + lev = cJSON_CreateObject(); cJSON_AddNumberToObject(lev, "value", 0); cJSON_AddStringToObject(lev, "name", "Off"); cJSON_AddItemToArray(loud_en_vals, lev); + lev = cJSON_CreateObject(); cJSON_AddNumberToObject(lev, "value", 1); cJSON_AddStringToObject(lev, "name", "On"); cJSON_AddItemToArray(loud_en_vals, lev); + cJSON_AddItemToObject(loud_en, "values", loud_en_vals); + cJSON_AddItemToArray(loud_params, loud_en); + + /* visibleWhen object for conditional params */ + cJSON *visibleWhen = cJSON_CreateObject(); + cJSON_AddNumberToObject(visibleWhen, "loudness_enabled", 1); + + /* Create grouped zones - each zone has threshold (except last), bass, treble */ + for (int i = 0; i < TAS5805M_LOUDNESS_ZONES; i++) { + cJSON *zone_group = cJSON_CreateObject(); + char zone_name[48]; + + /* Calculate volume range for this zone */ + int vol_start = (i == 0) ? 0 : loudness.thresholds[i - 1]; + int vol_end = (i < TAS5805M_LOUDNESS_ZONES - 1) ? loudness.thresholds[i] : 100; + snprintf(zone_name, sizeof(zone_name), "Zone %d (%d%% - %d%%)", i + 1, vol_start, vol_end); + + cJSON_AddStringToObject(zone_group, "name", zone_name); + cJSON_AddStringToObject(zone_group, "type", "loudness-zone"); + cJSON_AddNumberToObject(zone_group, "zone_index", i); + cJSON_AddItemToObject(zone_group, "visibleWhen", cJSON_Duplicate(visibleWhen, 1)); + + cJSON *zone_params = cJSON_CreateArray(); + + /* Threshold slider (zones 0-3 have thresholds, zone 4 ends at 100%) */ + if (i < TAS5805M_LOUDNESS_ZONES - 1) { + char tkey[24]; + snprintf(tkey, sizeof(tkey), "loudness_thresh_%d", i); + cJSON *thresh = cJSON_CreateObject(); + cJSON_AddStringToObject(thresh, "key", tkey); + cJSON_AddStringToObject(thresh, "name", "Upper Limit"); + cJSON_AddStringToObject(thresh, "type", "range"); + cJSON_AddStringToObject(thresh, "unit", "%"); + cJSON_AddNumberToObject(thresh, "min", 0); + cJSON_AddNumberToObject(thresh, "max", 100); + cJSON_AddNumberToObject(thresh, "step", 5); + cJSON_AddNumberToObject(thresh, "current", loudness.thresholds[i]); + cJSON_AddItemToArray(zone_params, thresh); + } + + /* Bass boost */ + char bkey[24]; + snprintf(bkey, sizeof(bkey), "loudness_bass_%d", i); + cJSON *bass = cJSON_CreateObject(); + cJSON_AddStringToObject(bass, "key", bkey); + cJSON_AddStringToObject(bass, "name", "Bass"); + cJSON_AddStringToObject(bass, "type", "range"); + cJSON_AddStringToObject(bass, "unit", "dB"); + cJSON_AddNumberToObject(bass, "min", -12); + cJSON_AddNumberToObject(bass, "max", 12); + cJSON_AddNumberToObject(bass, "step", 1); + cJSON_AddNumberToObject(bass, "current", loudness.bass_boost[i]); + cJSON_AddItemToArray(zone_params, bass); + + /* Treble boost */ + char trkey[24]; + snprintf(trkey, sizeof(trkey), "loudness_treble_%d", i); + cJSON *treble = cJSON_CreateObject(); + cJSON_AddStringToObject(treble, "key", trkey); + cJSON_AddStringToObject(treble, "name", "Treble"); + cJSON_AddStringToObject(treble, "type", "range"); + cJSON_AddStringToObject(treble, "unit", "dB"); + cJSON_AddNumberToObject(treble, "min", -12); + cJSON_AddNumberToObject(treble, "max", 12); + cJSON_AddNumberToObject(treble, "step", 1); + cJSON_AddNumberToObject(treble, "current", loudness.treble_boost[i]); + cJSON_AddItemToArray(zone_params, treble); + + cJSON_AddItemToObject(zone_group, "parameters", zone_params); + cJSON_AddItemToArray(loud_params, zone_group); + } + + cJSON_Delete(visibleWhen); + cJSON_AddItemToObject(loud_section, "parameters", loud_params); + cJSON_AddItemToArray(sections, loud_section); + } + + cJSON_AddItemToObject(biamp_group, "sections", sections); + cJSON_AddItemToArray(groups, biamp_group); + } + cJSON_AddItemToObject(root, "groups", groups); // Render to string @@ -2749,11 +3704,25 @@ esp_err_t tas5805m_settings_apply_delayed(void) { ESP_LOGI(TAG, "%s: Restored Channel Gain R = %d dB", __func__, ch_gain); } } + } else if (ui_mode == TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP) { + // Apply persisted advanced bi-amp crossover settings + tas5805m_biamp_settings_t biamp; + if (tas5805m_settings_load_biamp(&biamp) == ESP_OK) { + ESP_LOGI(TAG, "%s: Restoring advanced bi-amp settings: xover=%dHz slope=%d", + __func__, biamp.crossover_freq, biamp.slope); + if (tas5805m_settings_apply_biamp(&biamp) != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to apply saved bi-amp settings", __func__); + } else { + ESP_LOGI(TAG, "%s: Restored advanced bi-amp crossover", __func__); + } + } } - - // Restore channel gain for all EQ modes (not just presets) + + // Restore channel gain for all EQ modes (not just presets and advanced biamp) // Channel gain is independent of EQ band settings - if (ui_mode != TAS5805M_EQ_UI_MODE_OFF && ui_mode != TAS5805M_EQ_UI_MODE_PRESETS) { + // Note: Advanced Bi-Amp handles its own gains internally via crossover filters + if (ui_mode != TAS5805M_EQ_UI_MODE_OFF && ui_mode != TAS5805M_EQ_UI_MODE_PRESETS && + ui_mode != TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP) { int ch_gain = 0; if (tas5805m_settings_load_channel_gain(TAS5805M_EQ_CHANNELS_LEFT, &ch_gain) == ESP_OK) { if (tas5805m_set_channel_gain(TAS5805M_EQ_CHANNELS_LEFT, (int8_t)ch_gain) != ESP_OK) { @@ -2770,6 +3739,14 @@ esp_err_t tas5805m_settings_apply_delayed(void) { } } } + + /* Load loudness settings into the static cache and apply if enabled */ + if (tas5805m_settings_load_loudness(&s_loudness_settings) == ESP_OK && s_loudness_settings.enabled) { + int vol = 50; + tas5805m_get_volume(&vol); + ESP_LOGI(TAG, "%s: Restoring loudness compensation (enabled=%d, vol=%d%%)", __func__, s_loudness_settings.enabled, vol); + tas5805m_loudness_apply(vol); + } #endif ESP_LOGI(TAG, "%s: Delayed persisted settings application complete", __func__); @@ -2960,6 +3937,11 @@ esp_err_t tas5805m_settings_get_eq_json(char *json_out, size_t max_len) { snprintf(key, sizeof(key), "eq_gain_r_%d", band); cJSON_AddNumberToObject(root, key, gain_r); } + + // Get loudness enabled state (needed for UI visibility) + tas5805m_loudness_settings_t loudness; + tas5805m_settings_load_loudness(&loudness); + cJSON_AddNumberToObject(root, "loudness_enabled", loudness.enabled); #else // EQ support disabled cJSON_AddNumberToObject(root, "eq_mode", 0); @@ -3215,7 +4197,14 @@ esp_err_t tas5805m_settings_set_eq_from_json(const char *json_in) { ESP_LOGI(TAG, "%s: Applying saved preset right=%d", __func__, (int)prof_r); tas5805m_set_eq_profile_channel(TAS5805M_EQ_CHANNELS_RIGHT, prof_r); } - } + } else if (ui_mode == TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP) { + // Apply saved advanced bi-amp crossover settings + tas5805m_biamp_settings_t biamp; + if (tas5805m_settings_load_biamp(&biamp) == ESP_OK) { + ESP_LOGI(TAG, "%s: Applying saved advanced bi-amp settings", __func__); + tas5805m_settings_apply_biamp(&biamp); + } + } } else if (strcmp(key, "eq_profile_l") == 0 && cJSON_IsNumber(item)) { tas5805m_eq_profile_t prof = (tas5805m_eq_profile_t)item->valueint; @@ -3259,6 +4248,229 @@ esp_err_t tas5805m_settings_set_eq_from_json(const char *json_in) { tas5805m_settings_save_eq_gain(TAS5805M_EQ_CHANNELS_RIGHT, band, gain); } } + /* Advanced Bi-Amp parameter handling */ + else if (strncmp(key, "biamp_", 6) == 0 && cJSON_IsNumber(item)) { + /* Load current settings, modify, save, and apply only affected band */ + tas5805m_biamp_settings_t biamp; + tas5805m_settings_load_biamp(&biamp); + + /* Track what type of change for targeted apply */ + enum { + BIAMP_CHANGE_NONE = 0, + BIAMP_CHANGE_CROSSOVER, /* Requires full apply */ + BIAMP_CHANGE_LOW_GAIN_PHASE, + BIAMP_CHANGE_HIGH_GAIN_PHASE, + BIAMP_CHANGE_SUBSONIC, + BIAMP_CHANGE_TWEETER_DELAY, + BIAMP_CHANGE_BAFFLE_STEP, + BIAMP_CHANGE_NOTCH, + BIAMP_CHANGE_AIR, + BIAMP_CHANGE_LOW_PEQ, + BIAMP_CHANGE_HIGH_PEQ + } change_type = BIAMP_CHANGE_NONE; + int peq_band_changed = -1; + + if (strcmp(key, "biamp_xover_freq") == 0) { + /* Clamp crossover frequency to valid range (20Hz - 20000Hz) */ + int freq = item->valueint; + if (freq < 20) freq = 20; + if (freq > 20000) freq = 20000; + biamp.crossover_freq = (uint16_t)freq; + change_type = BIAMP_CHANGE_CROSSOVER; + ESP_LOGI(TAG, "%s: Setting bi-amp crossover freq to %d Hz", __func__, biamp.crossover_freq); + } else if (strcmp(key, "biamp_slope") == 0) { + biamp.slope = (tas5805m_biamp_slope_t)item->valueint; + change_type = BIAMP_CHANGE_CROSSOVER; + ESP_LOGI(TAG, "%s: Setting bi-amp slope to %d", __func__, biamp.slope); + } else if (strcmp(key, "biamp_type") == 0) { + biamp.type = (tas5805m_biamp_type_t)item->valueint; + change_type = BIAMP_CHANGE_CROSSOVER; + ESP_LOGI(TAG, "%s: Setting bi-amp type to %d", __func__, biamp.type); + } else if (strcmp(key, "biamp_sample_rate") == 0) { + biamp.sample_rate = (uint32_t)item->valueint; + change_type = BIAMP_CHANGE_CROSSOVER; + ESP_LOGI(TAG, "%s: Setting bi-amp sample rate to %lu Hz", __func__, (unsigned long)biamp.sample_rate); + } else if (strcmp(key, "biamp_subsonic_freq") == 0) { + biamp.subsonic_freq = (uint16_t)item->valueint; + change_type = BIAMP_CHANGE_SUBSONIC; + ESP_LOGI(TAG, "%s: Setting bi-amp subsonic filter to %d Hz", __func__, biamp.subsonic_freq); + } else if (strcmp(key, "biamp_low_gain") == 0) { + biamp.low_gain = (int8_t)(item->valuedouble * 2.0 + (item->valuedouble >= 0 ? 0.5 : -0.5)); + change_type = BIAMP_CHANGE_LOW_GAIN_PHASE; + ESP_LOGI(TAG, "%s: Setting bi-amp low gain to %.1f dB", __func__, item->valuedouble); + } else if (strcmp(key, "biamp_low_phase") == 0) { + biamp.low_phase_invert = (uint8_t)item->valueint; + change_type = BIAMP_CHANGE_LOW_GAIN_PHASE; + ESP_LOGI(TAG, "%s: Setting bi-amp low phase to %s", __func__, biamp.low_phase_invert ? "inverted" : "normal"); + } else if (strcmp(key, "biamp_high_gain") == 0) { + biamp.high_gain = (int8_t)(item->valuedouble * 2.0 + (item->valuedouble >= 0 ? 0.5 : -0.5)); + change_type = BIAMP_CHANGE_HIGH_GAIN_PHASE; + ESP_LOGI(TAG, "%s: Setting bi-amp high gain to %.1f dB", __func__, item->valuedouble); + } else if (strcmp(key, "biamp_high_phase") == 0) { + biamp.high_phase_invert = (uint8_t)item->valueint; + change_type = BIAMP_CHANGE_HIGH_GAIN_PHASE; + ESP_LOGI(TAG, "%s: Setting bi-amp high phase to %s", __func__, biamp.high_phase_invert ? "inverted" : "normal"); + } else if (strcmp(key, "biamp_baffle_width") == 0) { + biamp.baffle_width_cm = (uint8_t)item->valueint; + change_type = BIAMP_CHANGE_BAFFLE_STEP; + ESP_LOGI(TAG, "%s: Setting bi-amp baffle width to %d cm", __func__, biamp.baffle_width_cm); + } else if (strcmp(key, "biamp_baffle_placement") == 0) { + biamp.baffle_placement = (tas5805m_baffle_placement_t)item->valueint; + change_type = BIAMP_CHANGE_BAFFLE_STEP; + ESP_LOGI(TAG, "%s: Setting bi-amp baffle placement to %d", __func__, biamp.baffle_placement); + } else if (strcmp(key, "biamp_tweeter_delay") == 0) { + biamp.tweeter_delay_mm = (uint8_t)item->valueint; + change_type = BIAMP_CHANGE_TWEETER_DELAY; + ESP_LOGI(TAG, "%s: Setting bi-amp tweeter delay to %d mm", __func__, biamp.tweeter_delay_mm); + } else if (strcmp(key, "biamp_notch_freq") == 0) { + biamp.notch_freq = (uint16_t)item->valueint; + change_type = BIAMP_CHANGE_NOTCH; + ESP_LOGI(TAG, "%s: Setting bi-amp notch freq to %d Hz", __func__, biamp.notch_freq); + } else if (strcmp(key, "biamp_notch_gain") == 0) { + biamp.notch_gain = (int8_t)(item->valuedouble * 2.0 + (item->valuedouble >= 0 ? 0.5 : -0.5)); + change_type = BIAMP_CHANGE_NOTCH; + ESP_LOGI(TAG, "%s: Setting bi-amp notch gain to %.1f dB", __func__, item->valuedouble); + } else if (strcmp(key, "biamp_notch_q") == 0) { + biamp.notch_q_x10 = (uint8_t)(item->valuedouble * 10.0 + 0.5); + change_type = BIAMP_CHANGE_NOTCH; + ESP_LOGI(TAG, "%s: Setting bi-amp notch Q to %.1f", __func__, item->valuedouble); + } else if (strcmp(key, "biamp_air_gain") == 0) { + biamp.air_gain = (int8_t)(item->valuedouble * 2.0 + (item->valuedouble >= 0 ? 0.5 : -0.5)); + change_type = BIAMP_CHANGE_AIR; + ESP_LOGI(TAG, "%s: Setting bi-amp air gain to %.1f dB", __func__, item->valuedouble); + } + /* Low output PEQ bands */ + else if (strncmp(key, "biamp_low_peq", 13) == 0) { + int peq_idx = key[13] - '0'; + if (peq_idx >= 0 && peq_idx < TAS5805M_BIAMP_PEQ_BANDS) { + peq_band_changed = peq_idx; + if (strstr(key, "_freq") != NULL) { + biamp.low_peq[peq_idx].freq = (uint16_t)item->valueint; + change_type = BIAMP_CHANGE_LOW_PEQ; + ESP_LOGI(TAG, "%s: Setting bi-amp low PEQ %d freq to %d Hz", __func__, peq_idx, biamp.low_peq[peq_idx].freq); + } else if (strstr(key, "_gain") != NULL) { + biamp.low_peq[peq_idx].gain = (int8_t)(item->valuedouble * 2.0 + (item->valuedouble >= 0 ? 0.5 : -0.5)); + change_type = BIAMP_CHANGE_LOW_PEQ; + ESP_LOGI(TAG, "%s: Setting bi-amp low PEQ %d gain to %.1f dB", __func__, peq_idx, item->valuedouble); + } else if (strstr(key, "_q") != NULL) { + biamp.low_peq[peq_idx].q_x10 = (uint8_t)(item->valuedouble * 10.0 + 0.5); + change_type = BIAMP_CHANGE_LOW_PEQ; + ESP_LOGI(TAG, "%s: Setting bi-amp low PEQ %d Q to %.1f", __func__, peq_idx, item->valuedouble); + } + } + } + /* High output PEQ bands */ + else if (strncmp(key, "biamp_high_peq", 14) == 0) { + int peq_idx = key[14] - '0'; + if (peq_idx >= 0 && peq_idx < TAS5805M_BIAMP_PEQ_BANDS) { + peq_band_changed = peq_idx; + if (strstr(key, "_freq") != NULL) { + biamp.high_peq[peq_idx].freq = (uint16_t)item->valueint; + change_type = BIAMP_CHANGE_HIGH_PEQ; + ESP_LOGI(TAG, "%s: Setting bi-amp high PEQ %d freq to %d Hz", __func__, peq_idx, biamp.high_peq[peq_idx].freq); + } else if (strstr(key, "_gain") != NULL) { + biamp.high_peq[peq_idx].gain = (int8_t)(item->valuedouble * 2.0 + (item->valuedouble >= 0 ? 0.5 : -0.5)); + change_type = BIAMP_CHANGE_HIGH_PEQ; + ESP_LOGI(TAG, "%s: Setting bi-amp high PEQ %d gain to %.1f dB", __func__, peq_idx, item->valuedouble); + } else if (strstr(key, "_q") != NULL) { + biamp.high_peq[peq_idx].q_x10 = (uint8_t)(item->valuedouble * 10.0 + 0.5); + change_type = BIAMP_CHANGE_HIGH_PEQ; + ESP_LOGI(TAG, "%s: Setting bi-amp high PEQ %d Q to %.1f", __func__, peq_idx, item->valuedouble); + } + } + } + + if (change_type != BIAMP_CHANGE_NONE) { + tas5805m_settings_save_biamp(&biamp); + + /* Apply only if we're in advanced bi-amp mode */ + TAS5805M_EQ_UI_MODE ui_mode = TAS5805M_EQ_UI_MODE_OFF; + tas5805m_settings_load_eq_ui_mode(&ui_mode); + if (ui_mode == TAS5805M_EQ_UI_MODE_ADVANCED_BIAMP) { + /* Apply only the affected band(s) */ + switch (change_type) { + case BIAMP_CHANGE_CROSSOVER: + /* Crossover changes affect multiple bands - do full apply */ + tas5805m_settings_apply_biamp(&biamp); + break; + case BIAMP_CHANGE_LOW_GAIN_PHASE: + tas5805m_biamp_apply_low_gain_phase(biamp.low_gain, biamp.low_phase_invert, biamp.sample_rate); + break; + case BIAMP_CHANGE_HIGH_GAIN_PHASE: + tas5805m_biamp_apply_high_gain_phase(biamp.high_gain, biamp.high_phase_invert, biamp.sample_rate); + break; + case BIAMP_CHANGE_SUBSONIC: + tas5805m_biamp_apply_subsonic(biamp.subsonic_freq, biamp.sample_rate); + break; + case BIAMP_CHANGE_TWEETER_DELAY: + tas5805m_biamp_apply_tweeter_delay(biamp.tweeter_delay_mm, biamp.sample_rate); + break; + case BIAMP_CHANGE_BAFFLE_STEP: + tas5805m_biamp_apply_baffle_step(biamp.baffle_width_cm, biamp.baffle_placement, biamp.sample_rate); + break; + case BIAMP_CHANGE_NOTCH: + tas5805m_biamp_apply_notch(biamp.notch_freq, biamp.notch_gain, biamp.notch_q_x10, biamp.sample_rate); + break; + case BIAMP_CHANGE_AIR: + tas5805m_biamp_apply_air_shelf(biamp.air_gain, biamp.sample_rate); + break; + case BIAMP_CHANGE_LOW_PEQ: + if (peq_band_changed >= 0) { + tas5805m_biamp_apply_low_peq(peq_band_changed, &biamp.low_peq[peq_band_changed], biamp.sample_rate); + } + break; + case BIAMP_CHANGE_HIGH_PEQ: + if (peq_band_changed >= 0) { + tas5805m_biamp_apply_high_peq(peq_band_changed, &biamp.high_peq[peq_band_changed], biamp.sample_rate); + } + break; + default: + break; + } + } + } + } + /* Loudness compensation parameter handling - update static cache directly */ + else if (strncmp(key, "loudness_", 9) == 0 && cJSON_IsNumber(item)) { + /* Ensure cache is loaded from NVS before modifying */ + tas5805m_settings_load_loudness(&s_loudness_settings); + bool modified = false; + + if (strcmp(key, "loudness_enabled") == 0) { + s_loudness_settings.enabled = (uint8_t)item->valueint; + modified = true; + ESP_LOGI(TAG, "%s: Setting loudness enabled to %d", __func__, s_loudness_settings.enabled); + } else if (strncmp(key, "loudness_thresh_", 16) == 0) { + int idx = key[16] - '0'; + if (idx >= 0 && idx < TAS5805M_LOUDNESS_ZONES - 1) { + s_loudness_settings.thresholds[idx] = (uint8_t)item->valueint; + modified = true; + ESP_LOGI(TAG, "%s: Setting loudness threshold %d to %d%%", __func__, idx, s_loudness_settings.thresholds[idx]); + } + } else if (strncmp(key, "loudness_bass_", 14) == 0) { + int idx = key[14] - '0'; + if (idx >= 0 && idx < TAS5805M_LOUDNESS_ZONES) { + s_loudness_settings.bass_boost[idx] = (int8_t)item->valueint; + modified = true; + ESP_LOGI(TAG, "%s: Setting loudness bass zone %d to %d dB", __func__, idx, s_loudness_settings.bass_boost[idx]); + } + } else if (strncmp(key, "loudness_treble_", 16) == 0) { + int idx = key[16] - '0'; + if (idx >= 0 && idx < TAS5805M_LOUDNESS_ZONES) { + s_loudness_settings.treble_boost[idx] = (int8_t)item->valueint; + modified = true; + ESP_LOGI(TAG, "%s: Setting loudness treble zone %d to %d dB", __func__, idx, s_loudness_settings.treble_boost[idx]); + } + } + + if (modified) { + tas5805m_settings_save_loudness(&s_loudness_settings); + /* Re-apply loudness with current volume */ + int vol = 50; + tas5805m_get_volume(&vol); + tas5805m_loudness_apply(vol); + } + } } #endif @@ -3266,4 +4478,433 @@ esp_err_t tas5805m_settings_set_eq_from_json(const char *json_in) { return ESP_OK; } +/* ============ Bi-Amp Preset Export/Import ============ */ + +esp_err_t tas5805m_biamp_preset_export(char *json_out, size_t max_len) +{ + if (!json_out || max_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + /* Load current settings */ + tas5805m_biamp_settings_t biamp; + tas5805m_settings_load_biamp(&biamp); + + tas5805m_loudness_settings_t loudness; + tas5805m_settings_load_loudness(&loudness); + + /* Build JSON preset */ + cJSON *root = cJSON_CreateObject(); + if (!root) { + return ESP_ERR_NO_MEM; + } + + /* Preset metadata */ + cJSON_AddNumberToObject(root, "version", TAS5805M_BIAMP_PRESET_VERSION); + cJSON_AddStringToObject(root, "type", "biamp_preset"); + + /* Crossover settings */ + cJSON *crossover = cJSON_CreateObject(); + cJSON_AddNumberToObject(crossover, "frequency", biamp.crossover_freq); + cJSON_AddNumberToObject(crossover, "slope", biamp.slope); + cJSON_AddNumberToObject(crossover, "filter_type", biamp.type); + cJSON_AddItemToObject(root, "crossover", crossover); + + /* Subsonic filter */ + cJSON_AddNumberToObject(root, "subsonic_freq", biamp.subsonic_freq); + + /* Baffle step compensation */ + cJSON *baffle = cJSON_CreateObject(); + cJSON_AddNumberToObject(baffle, "width_cm", biamp.baffle_width_cm); + cJSON_AddNumberToObject(baffle, "placement", biamp.baffle_placement); + cJSON_AddItemToObject(root, "baffle_step", baffle); + + /* Low output (woofer) - gains stored as x2, export as actual dB */ + cJSON *low = cJSON_CreateObject(); + cJSON_AddNumberToObject(low, "gain", biamp.low_gain / 2.0); + cJSON_AddNumberToObject(low, "phase_invert", biamp.low_phase_invert); + cJSON *low_peq = cJSON_CreateArray(); + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + cJSON *band = cJSON_CreateObject(); + cJSON_AddNumberToObject(band, "freq", biamp.low_peq[i].freq); + cJSON_AddNumberToObject(band, "gain", biamp.low_peq[i].gain / 2.0); + cJSON_AddNumberToObject(band, "q", biamp.low_peq[i].q_x10 / 10.0); + cJSON_AddItemToArray(low_peq, band); + } + cJSON_AddItemToObject(low, "peq", low_peq); + cJSON_AddItemToObject(root, "low_output", low); + + /* High output (tweeter) - gains stored as x2, export as actual dB */ + cJSON *high = cJSON_CreateObject(); + cJSON_AddNumberToObject(high, "gain", biamp.high_gain / 2.0); + cJSON_AddNumberToObject(high, "phase_invert", biamp.high_phase_invert); + cJSON_AddNumberToObject(high, "delay_mm", biamp.tweeter_delay_mm); + cJSON_AddNumberToObject(high, "notch_freq", biamp.notch_freq); + cJSON_AddNumberToObject(high, "notch_gain", biamp.notch_gain / 2.0); + cJSON_AddNumberToObject(high, "notch_q", biamp.notch_q_x10 / 10.0); + cJSON_AddNumberToObject(high, "air_gain", biamp.air_gain / 2.0); + cJSON *high_peq = cJSON_CreateArray(); + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + cJSON *band = cJSON_CreateObject(); + cJSON_AddNumberToObject(band, "freq", biamp.high_peq[i].freq); + cJSON_AddNumberToObject(band, "gain", biamp.high_peq[i].gain / 2.0); + cJSON_AddNumberToObject(band, "q", biamp.high_peq[i].q_x10 / 10.0); + cJSON_AddItemToArray(high_peq, band); + } + cJSON_AddItemToObject(high, "peq", high_peq); + cJSON_AddItemToObject(root, "high_output", high); + + /* Loudness compensation */ + cJSON *loud = cJSON_CreateObject(); + cJSON_AddNumberToObject(loud, "enabled", loudness.enabled); + cJSON *thresholds = cJSON_CreateArray(); + for (int i = 0; i < TAS5805M_LOUDNESS_ZONES - 1; i++) { + cJSON_AddItemToArray(thresholds, cJSON_CreateNumber(loudness.thresholds[i])); + } + cJSON_AddItemToObject(loud, "thresholds", thresholds); + cJSON *bass = cJSON_CreateArray(); + cJSON *treble = cJSON_CreateArray(); + for (int i = 0; i < TAS5805M_LOUDNESS_ZONES; i++) { + cJSON_AddItemToArray(bass, cJSON_CreateNumber(loudness.bass_boost[i])); + cJSON_AddItemToArray(treble, cJSON_CreateNumber(loudness.treble_boost[i])); + } + cJSON_AddItemToObject(loud, "bass_boost", bass); + cJSON_AddItemToObject(loud, "treble_boost", treble); + cJSON_AddItemToObject(root, "loudness", loud); + + /* Serialize to string */ + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + + if (!json_str) { + return ESP_ERR_NO_MEM; + } + + size_t len = strlen(json_str); + if (len >= max_len) { + free(json_str); + return ESP_ERR_INVALID_SIZE; + } + + strcpy(json_out, json_str); + free(json_str); + + ESP_LOGI(TAG, "%s: Exported bi-amp preset (%d bytes)", __func__, len); + return ESP_OK; +} + +esp_err_t tas5805m_biamp_preset_import(const char *json_in) +{ + if (!json_in) { + return ESP_ERR_INVALID_ARG; + } + + cJSON *root = cJSON_Parse(json_in); + if (!root) { + ESP_LOGE(TAG, "%s: Failed to parse JSON", __func__); + return ESP_ERR_INVALID_ARG; + } + + /* Verify preset type and version */ + cJSON *type = cJSON_GetObjectItem(root, "type"); + if (!type || !cJSON_IsString(type) || strcmp(type->valuestring, "biamp_preset") != 0) { + ESP_LOGE(TAG, "%s: Invalid preset type", __func__); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + cJSON *version = cJSON_GetObjectItem(root, "version"); + if (!version || !cJSON_IsNumber(version) || version->valueint > TAS5805M_BIAMP_PRESET_VERSION) { + ESP_LOGE(TAG, "%s: Unsupported preset version", __func__); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + /* Load current settings as defaults */ + tas5805m_biamp_settings_t biamp; + tas5805m_biamp_init_defaults(&biamp); + + tas5805m_loudness_settings_t loudness; + tas5805m_loudness_init_defaults(&loudness); + + /* Parse crossover settings */ + cJSON *crossover = cJSON_GetObjectItem(root, "crossover"); + if (crossover) { + cJSON *freq = cJSON_GetObjectItem(crossover, "frequency"); + if (freq && cJSON_IsNumber(freq)) { + /* Clamp crossover frequency to valid range (20Hz - 20000Hz) */ + int f = freq->valueint; + if (f < 20) f = 20; + if (f > 20000) f = 20000; + biamp.crossover_freq = (uint16_t)f; + } + + cJSON *slope = cJSON_GetObjectItem(crossover, "slope"); + if (slope && cJSON_IsNumber(slope)) biamp.slope = (tas5805m_biamp_slope_t)slope->valueint; + + cJSON *ftype = cJSON_GetObjectItem(crossover, "filter_type"); + if (ftype && cJSON_IsNumber(ftype)) biamp.type = (tas5805m_biamp_type_t)ftype->valueint; + } + + /* Parse subsonic filter */ + cJSON *subsonic = cJSON_GetObjectItem(root, "subsonic_freq"); + if (subsonic && cJSON_IsNumber(subsonic)) biamp.subsonic_freq = (uint16_t)subsonic->valueint; + + /* Parse baffle step compensation */ + cJSON *baffle = cJSON_GetObjectItem(root, "baffle_step"); + if (baffle) { + cJSON *width = cJSON_GetObjectItem(baffle, "width_cm"); + if (width && cJSON_IsNumber(width)) biamp.baffle_width_cm = (uint8_t)width->valueint; + + cJSON *placement = cJSON_GetObjectItem(baffle, "placement"); + if (placement && cJSON_IsNumber(placement)) biamp.baffle_placement = (tas5805m_baffle_placement_t)placement->valueint; + } + + /* Parse low output - gains in preset are actual dB, store as x2 */ + cJSON *low = cJSON_GetObjectItem(root, "low_output"); + if (low) { + cJSON *gain = cJSON_GetObjectItem(low, "gain"); + if (gain && cJSON_IsNumber(gain)) { + biamp.low_gain = (int8_t)(gain->valuedouble * 2.0 + (gain->valuedouble >= 0 ? 0.5 : -0.5)); + } + + cJSON *phase = cJSON_GetObjectItem(low, "phase_invert"); + if (phase && cJSON_IsNumber(phase)) biamp.low_phase_invert = (uint8_t)phase->valueint; + + cJSON *peq = cJSON_GetObjectItem(low, "peq"); + if (peq && cJSON_IsArray(peq)) { + int idx = 0; + cJSON *band; + cJSON_ArrayForEach(band, peq) { + if (idx >= TAS5805M_BIAMP_PEQ_BANDS) break; + cJSON *f = cJSON_GetObjectItem(band, "freq"); + cJSON *g = cJSON_GetObjectItem(band, "gain"); + cJSON *q = cJSON_GetObjectItem(band, "q"); + if (f && cJSON_IsNumber(f)) biamp.low_peq[idx].freq = (uint16_t)f->valueint; + if (g && cJSON_IsNumber(g)) { + biamp.low_peq[idx].gain = (int8_t)(g->valuedouble * 2.0 + (g->valuedouble >= 0 ? 0.5 : -0.5)); + } + if (q && cJSON_IsNumber(q)) biamp.low_peq[idx].q_x10 = (uint8_t)(q->valuedouble * 10.0 + 0.5); + idx++; + } + } + } + + /* Parse high output - gains in preset are actual dB, store as x2 */ + cJSON *high = cJSON_GetObjectItem(root, "high_output"); + if (high) { + cJSON *gain = cJSON_GetObjectItem(high, "gain"); + if (gain && cJSON_IsNumber(gain)) { + biamp.high_gain = (int8_t)(gain->valuedouble * 2.0 + (gain->valuedouble >= 0 ? 0.5 : -0.5)); + } + + cJSON *phase = cJSON_GetObjectItem(high, "phase_invert"); + if (phase && cJSON_IsNumber(phase)) biamp.high_phase_invert = (uint8_t)phase->valueint; + + cJSON *delay = cJSON_GetObjectItem(high, "delay_mm"); + if (delay && cJSON_IsNumber(delay)) biamp.tweeter_delay_mm = (uint8_t)delay->valueint; + + cJSON *notch_freq = cJSON_GetObjectItem(high, "notch_freq"); + if (notch_freq && cJSON_IsNumber(notch_freq)) biamp.notch_freq = (uint16_t)notch_freq->valueint; + + cJSON *notch_gain = cJSON_GetObjectItem(high, "notch_gain"); + if (notch_gain && cJSON_IsNumber(notch_gain)) { + biamp.notch_gain = (int8_t)(notch_gain->valuedouble * 2.0 + (notch_gain->valuedouble >= 0 ? 0.5 : -0.5)); + } + + cJSON *notch_q = cJSON_GetObjectItem(high, "notch_q"); + if (notch_q && cJSON_IsNumber(notch_q)) biamp.notch_q_x10 = (uint8_t)(notch_q->valuedouble * 10.0 + 0.5); + + cJSON *air_gain_val = cJSON_GetObjectItem(high, "air_gain"); + if (air_gain_val && cJSON_IsNumber(air_gain_val)) { + biamp.air_gain = (int8_t)(air_gain_val->valuedouble * 2.0 + (air_gain_val->valuedouble >= 0 ? 0.5 : -0.5)); + } + + cJSON *peq = cJSON_GetObjectItem(high, "peq"); + if (peq && cJSON_IsArray(peq)) { + int idx = 0; + cJSON *band; + cJSON_ArrayForEach(band, peq) { + if (idx >= TAS5805M_BIAMP_PEQ_BANDS) break; + cJSON *f = cJSON_GetObjectItem(band, "freq"); + cJSON *g = cJSON_GetObjectItem(band, "gain"); + cJSON *q = cJSON_GetObjectItem(band, "q"); + if (f && cJSON_IsNumber(f)) biamp.high_peq[idx].freq = (uint16_t)f->valueint; + if (g && cJSON_IsNumber(g)) { + biamp.high_peq[idx].gain = (int8_t)(g->valuedouble * 2.0 + (g->valuedouble >= 0 ? 0.5 : -0.5)); + } + if (q && cJSON_IsNumber(q)) biamp.high_peq[idx].q_x10 = (uint8_t)(q->valuedouble * 10.0 + 0.5); + idx++; + } + } + } + + /* Parse loudness settings */ + cJSON *loud = cJSON_GetObjectItem(root, "loudness"); + if (loud) { + cJSON *enabled = cJSON_GetObjectItem(loud, "enabled"); + if (enabled && cJSON_IsNumber(enabled)) loudness.enabled = (uint8_t)enabled->valueint; + + cJSON *thresholds = cJSON_GetObjectItem(loud, "thresholds"); + if (thresholds && cJSON_IsArray(thresholds)) { + int idx = 0; + cJSON *val; + cJSON_ArrayForEach(val, thresholds) { + if (idx >= TAS5805M_LOUDNESS_ZONES - 1) break; + if (cJSON_IsNumber(val)) loudness.thresholds[idx] = (uint8_t)val->valueint; + idx++; + } + } + + cJSON *bass = cJSON_GetObjectItem(loud, "bass_boost"); + if (bass && cJSON_IsArray(bass)) { + int idx = 0; + cJSON *val; + cJSON_ArrayForEach(val, bass) { + if (idx >= TAS5805M_LOUDNESS_ZONES) break; + if (cJSON_IsNumber(val)) loudness.bass_boost[idx] = (int8_t)val->valueint; + idx++; + } + } + + cJSON *treble = cJSON_GetObjectItem(loud, "treble_boost"); + if (treble && cJSON_IsArray(treble)) { + int idx = 0; + cJSON *val; + cJSON_ArrayForEach(val, treble) { + if (idx >= TAS5805M_LOUDNESS_ZONES) break; + if (cJSON_IsNumber(val)) loudness.treble_boost[idx] = (int8_t)val->valueint; + idx++; + } + } + } + + cJSON_Delete(root); + + /* Save and apply settings */ + esp_err_t ret = tas5805m_settings_save_biamp(&biamp); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to save bi-amp settings", __func__); + return ret; + } + + ret = tas5805m_settings_save_loudness(&loudness); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to save loudness settings", __func__); + return ret; + } + + /* Apply the settings */ + ret = tas5805m_biamp_apply(&biamp); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to apply bi-amp settings (codec may not be ready)", __func__); + } + + /* Re-apply loudness with current volume */ + int vol = 50; + tas5805m_get_volume(&vol); + tas5805m_loudness_apply(vol); + + ESP_LOGI(TAG, "%s: Imported bi-amp preset successfully", __func__); + return ESP_OK; +} + +/* ============ Bi-Amp Reset to Defaults ============ */ + +esp_err_t tas5805m_biamp_reset_defaults(void) +{ + ESP_LOGI(TAG, "%s: Resetting bi-amp settings to defaults", __func__); + + nvs_handle_t nvs; + esp_err_t ret = nvs_open(TAS5805M_NVS_NAMESPACE, NVS_READWRITE, &nvs); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to open NVS: %s", __func__, esp_err_to_name(ret)); + return ret; + } + + /* Erase all bi-amp related NVS keys */ + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_XOVER_FREQ); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_SLOPE); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_TYPE); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_LOW_GAIN); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_LOW_PHASE); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_HIGH_GAIN); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_HIGH_PHASE); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_SAMPLE_RATE); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BIAMP_SUBSONIC_FREQ); + + /* Erase baffle step keys */ + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BAFFLE_WIDTH); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_BAFFLE_PLACEMENT); + + /* Erase tweeter-specific keys */ + nvs_erase_key(nvs, TAS5805M_NVS_KEY_TWEETER_DELAY); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_NOTCH_FREQ); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_NOTCH_GAIN); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_NOTCH_Q); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_AIR_GAIN); + + /* Erase PEQ keys for both channels */ + char key[32]; + for (int i = 0; i < TAS5805M_BIAMP_PEQ_BANDS; i++) { + snprintf(key, sizeof(key), "%s%d_f", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + nvs_erase_key(nvs, key); + snprintf(key, sizeof(key), "%s%d_g", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + nvs_erase_key(nvs, key); + snprintf(key, sizeof(key), "%s%d_q", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_L, i); + nvs_erase_key(nvs, key); + + snprintf(key, sizeof(key), "%s%d_f", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + nvs_erase_key(nvs, key); + snprintf(key, sizeof(key), "%s%d_g", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + nvs_erase_key(nvs, key); + snprintf(key, sizeof(key), "%s%d_q", TAS5805M_NVS_KEY_BIAMP_PEQ_PREFIX_H, i); + nvs_erase_key(nvs, key); + } + + /* Erase loudness keys */ + nvs_erase_key(nvs, TAS5805M_NVS_KEY_LOUDNESS_ENABLED); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_LOUDNESS_THRESH); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_LOUDNESS_BASS); + nvs_erase_key(nvs, TAS5805M_NVS_KEY_LOUDNESS_TREBLE); + + nvs_commit(nvs); + nvs_close(nvs); + + /* Initialize bi-amp settings to defaults */ + tas5805m_biamp_settings_t biamp; + tas5805m_biamp_init_defaults(&biamp); + + /* Initialize loudness settings to defaults */ + tas5805m_loudness_settings_t loudness; + tas5805m_loudness_init_defaults(&loudness); + + /* Save the default settings */ + ret = tas5805m_settings_save_biamp(&biamp); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to save default bi-amp settings", __func__); + return ret; + } + + ret = tas5805m_settings_save_loudness(&loudness); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to save default loudness settings", __func__); + return ret; + } + + /* Apply the default settings */ + ret = tas5805m_biamp_apply(&biamp); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to apply bi-amp settings (codec may not be ready)", __func__); + } + + /* Re-apply loudness with current volume */ + int vol = 50; + tas5805m_get_volume(&vol); + tas5805m_loudness_apply(vol); + + ESP_LOGI(TAG, "%s: Bi-amp settings reset to defaults successfully", __func__); + return ESP_OK; +} + #endif /* CONFIG_DAC_TAS5805M */ diff --git a/components/ui_http_server/html/dac-settings.html b/components/ui_http_server/html/dac-settings.html index 8618c8dc..0c603a6f 100644 --- a/components/ui_http_server/html/dac-settings.html +++ b/components/ui_http_server/html/dac-settings.html @@ -453,26 +453,39 @@

TAS5805M DAC Settings

} // Fetch DAC schema from the device - async function loadSchema() { + async function loadSchema(retryCount = 0) { try { + // Check if getRequest is available (index.js loaded) + if (typeof getRequest !== 'function') { + throw new Error('Script not loaded - please refresh the page'); + } + const response = await getRequest('/api/dac/schema'); if (!response.ok) { - throw new Error('Failed to fetch DAC schema'); + throw new Error(`Server error: ${response.status}`); } schema = await response.json(); - + // Load current settings await loadCurrentSettings(); - + renderUI(); - + // Initialize polling UI and start periodic polling if enabled initPollingControls(); startPolling(); } catch (error) { console.error('Error loading DAC schema:', error); - document.getElementById('app').innerHTML = - '
Error loading DAC settings. Please refresh the page.
'; + + // Retry once after a short delay (helps with mobile timing issues) + if (retryCount < 1) { + console.log('Retrying DAC schema load...'); + setTimeout(() => loadSchema(retryCount + 1), 500); + return; + } + + document.getElementById('app').innerHTML = + `
Error loading DAC settings: ${error.message}
`; } } diff --git a/components/ui_http_server/html/eq-settings.html b/components/ui_http_server/html/eq-settings.html index 9f3c0aec..15cdcf1d 100644 --- a/components/ui_http_server/html/eq-settings.html +++ b/components/ui_http_server/html/eq-settings.html @@ -279,6 +279,169 @@ box-sizing: border-box; } + /* Radio button group styles */ + .radio-group-label { + display: block; + font-weight: bold; + margin-bottom: 8px; + color: #333; + } + + .radio-group { + display: flex; + gap: 15px; + flex-wrap: wrap; + } + + .radio-option { + display: inline-flex; + align-items: center; + cursor: pointer; + padding: 8px 16px; + border: 2px solid #ddd; + border-radius: 20px; + background-color: #f8f9fa; + transition: all 0.2s ease; + font-weight: normal; + } + + .radio-option:hover { + border-color: #007bff; + background-color: #e7f3ff; + } + + .radio-option input[type="radio"] { + margin-right: 6px; + accent-color: #007bff; + } + + .radio-option:has(input[type="radio"]:checked) { + border-color: #007bff; + background-color: #007bff; + color: white; + } + + .radio-option input[type="radio"]:checked + span { + color: white; + } + + .radio-option input[type="radio"]:disabled { + cursor: not-allowed; + } + + .radio-option:has(input[type="radio"]:disabled) { + cursor: not-allowed; + opacity: 0.6; + } + + /* PEQ Subgroup styles */ + .peq-subgroup { + background-color: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 10px; + margin-bottom: 10px; + } + + .peq-subgroup-header { + font-weight: bold; + font-size: 14px; + color: #555; + margin-bottom: 8px; + padding-bottom: 5px; + border-bottom: 1px solid #ddd; + } + + .peq-subgroup-params { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + } + + .peq-param { + display: flex; + flex-direction: column; + align-items: center; + } + + .peq-param label { + font-size: 11px; + color: #666; + margin-bottom: 4px; + font-weight: normal; + } + + .peq-param input[type="range"] { + width: 100%; + height: 6px; + margin: 0; + } + + .peq-param-value { + font-size: 12px; + color: #007bff; + font-weight: bold; + margin-top: 4px; + } + + /* Output channel section styles */ + .section-output-channel .section-params { + display: flex; + flex-direction: column; + gap: 12px; + } + + .section-output-channel .parameter-control { + margin-bottom: 0; + } + + /* Preset buttons */ + .preset-buttons { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid #e0e0e0; + } + + .preset-btn { + padding: 10px 20px; + font-size: 14px; + font-weight: bold; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + } + + .preset-btn.export-btn { + background-color: #28a745; + color: white; + } + + .preset-btn.export-btn:hover { + background-color: #218838; + } + + .preset-btn.import-btn { + background-color: #007bff; + color: white; + } + + .preset-btn.import-btn:hover { + background-color: #0056b3; + } + + .preset-btn.reset-btn { + background-color: #dc3545; + color: white; + } + + .preset-btn.reset-btn:hover { + background-color: #c82333; + } + .info-box { background-color: #d1ecf1; color: #0c5460; @@ -382,10 +545,239 @@ gap: 10px; margin-top: 20px; } - + .button-group button { flex: 1; } + + /* Bi-Amp Crossover Sections Layout */ + .biamp-sections-container { + display: flex; + flex-direction: column; + gap: 20px; + } + + .biamp-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + } + + @media (max-width: 768px) { + .biamp-columns { + grid-template-columns: 1fr; + } + } + + .biamp-section { + background-color: #f8f9fa; + border-radius: 8px; + padding: 15px; + } + + .biamp-section .section-title { + margin: 0 0 15px 0; + font-size: 16px; + font-weight: bold; + color: #495057; + border-bottom: 1px solid #dee2e6; + padding-bottom: 8px; + } + + .biamp-section.section-vertical-sliders .section-params { + display: flex; + flex-wrap: wrap; + gap: 15px; + justify-content: center; + } + + .biamp-section.section-vertical-sliders .eq-band-slider { + min-width: 50px; + } + + .biamp-section.section-vertical-sliders .eq-band-slider input[type="range"] { + height: 150px; + } + + .biamp-section.section-loudness .section-params { + display: flex; + flex-direction: column; + gap: 10px; + } + + /* Loudness Zone grouped layout */ + .loudness-zone { + background-color: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + } + + .loudness-zone-header { + font-weight: bold; + font-size: 14px; + color: #495057; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid #dee2e6; + } + + .loudness-zone-params { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 12px; + } + + .loudness-zone-param { + display: flex; + flex-direction: column; + align-items: center; + } + + .loudness-zone-param label { + font-size: 12px; + color: #666; + margin-bottom: 4px; + font-weight: 500; + } + + .loudness-zone-param input[type="range"] { + width: 100%; + height: 6px; + margin: 4px 0; + } + + .loudness-zone-value { + font-size: 13px; + color: #007bff; + font-weight: bold; + margin-top: 4px; + } + + /* Section-specific layout for loudness */ + .section-loudness .section-params { + display: flex; + flex-direction: column; + gap: 8px; + } + + /* Mobile responsive styles */ + @media (max-width: 768px) { + body { + padding: 10px; + margin: 10px auto; + } + + h1 { + font-size: 22px; + margin-bottom: 15px; + } + + .group-container, + .parameter-group { + padding: 12px; + } + + .group-title { + font-size: 18px; + } + + .parameter-group h2 { + font-size: 16px; + } + + .peq-subgroup-params { + grid-template-columns: 1fr; + gap: 8px; + } + + .peq-param { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .peq-param label { + margin-bottom: 0; + min-width: 50px; + } + + .peq-param input[type="range"] { + flex: 1; + margin: 0 10px; + } + + .radio-group { + flex-direction: column; + gap: 8px; + } + + .radio-option { + width: 100%; + justify-content: center; + } + + .preset-buttons { + flex-direction: column; + gap: 8px; + } + + .preset-btn { + width: 100%; + } + + .current-state { + flex-direction: column; + gap: 8px; + text-align: center; + } + + .eq-bands-container { + justify-content: flex-start; + } + + .eq-band-slider input[type="range"] { + height: 150px; + } + } + + /* Extra small mobile */ + @media (max-width: 480px) { + body { + padding: 8px; + margin: 5px; + } + + h1 { + font-size: 18px; + } + + .group-container, + .parameter-group { + padding: 10px; + } + + .parameter-control .param-name { + font-size: 14px; + } + + .parameter-control .param-value { + font-size: 16px; + } + + .biamp-section { + padding: 10px; + } + + .biamp-section .section-title { + font-size: 14px; + } + + .loudness-zone-params { + grid-template-columns: 1fr; + } + } @@ -399,6 +791,7 @@

EQ Settings

let currentSettings = {}; let schema = {}; + const debounceTimers = {}; // Get backend URL from query parameter or default to current origin function getBackendUrl() { @@ -408,31 +801,43 @@

EQ Settings

} // Fetch EQ schema and current settings - async function loadSettings() { + async function loadSettings(retryCount = 0) { try { const backendUrl = getBackendUrl(); - - // Fetch schema + + // Fetch schema first const schemaResponse = await fetch(`${backendUrl}/api/eq/schema`); if (!schemaResponse.ok) { - throw new Error('Failed to fetch EQ schema'); + throw new Error(`Schema fetch failed: ${schemaResponse.status}`); } schema = await schemaResponse.json(); - + + // Delay between requests to allow ESP32 memory recovery after 64KB schema + await new Promise(resolve => setTimeout(resolve, 300)); + // Fetch current settings const settingsResponse = await fetch(`${backendUrl}/api/eq/settings`); if (!settingsResponse.ok) { - throw new Error('Failed to fetch EQ settings'); + throw new Error(`Settings fetch failed: ${settingsResponse.status}`); } currentSettings = await settingsResponse.json(); - + // Render UI renderUI(); } catch (error) { console.error('Error loading EQ settings:', error); + + // Retry once after a short delay (helps with mobile timing issues) + if (retryCount < 1) { + console.log('Retrying EQ settings load...'); + setTimeout(() => loadSettings(retryCount + 1), 500); + return; + } + document.getElementById('content').innerHTML = `
Error loading EQ settings: ${error.message} +
`; } @@ -504,22 +909,136 @@

EQ Settings

if (group.layout === 'eq-bands' && group.parameters) { const bandsContainer = document.createElement('div'); bandsContainer.className = 'eq-bands-container'; - + group.parameters.forEach(param => { const sliderDiv = settingsUI.renderVerticalSlider(param, currentSettings, async (k, v) => { await updateSetting(k, v); }); bandsContainer.appendChild(sliderDiv); }); - + contentWrapper.appendChild(bandsContainer); groupDiv.appendChild(contentWrapper); updateGroupVisibility(groupDiv); return groupDiv; } + // Handle biamp-crossover layout with sections + if (group.layout === 'biamp-crossover' && group.sections) { + const sectionsContainer = document.createElement('div'); + sectionsContainer.className = 'biamp-sections-container'; + + group.sections.forEach(section => { + if (section.columns) { + // Handle columned sections (Low/High side by side) + const columnsDiv = document.createElement('div'); + columnsDiv.className = 'biamp-columns'; + section.columns.forEach(col => { + const colSection = settingsUI.renderSection(col, currentSettings, async (k, v) => { + await updateSetting(k, v); + // Update conditional visibility after setting change + settingsUI.updateConditionalVisibility(groupDiv, currentSettings); + }); + columnsDiv.appendChild(colSection); + }); + sectionsContainer.appendChild(columnsDiv); + } else { + // Regular section + const sectionDiv = settingsUI.renderSection(section, currentSettings, async (k, v) => { + await updateSetting(k, v); + // Update conditional visibility after setting change + settingsUI.updateConditionalVisibility(groupDiv, currentSettings); + }); + sectionsContainer.appendChild(sectionDiv); + } + }); + + // Add preset export/import buttons + const presetButtonsDiv = document.createElement('div'); + presetButtonsDiv.className = 'preset-buttons'; + + const exportBtn = document.createElement('button'); + exportBtn.className = 'preset-btn export-btn'; + exportBtn.textContent = 'Export Preset'; + exportBtn.onclick = async () => { + try { + const response = await fetch(`${getBackendUrl()}/api/biamp/preset`); + if (!response.ok) throw new Error('Export failed'); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'biamp_preset.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + alert('Failed to export preset: ' + err.message); + } + }; + presetButtonsDiv.appendChild(exportBtn); + + const importBtn = document.createElement('button'); + importBtn.className = 'preset-btn import-btn'; + importBtn.textContent = 'Import Preset'; + importBtn.onclick = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const response = await fetch(`${getBackendUrl()}/api/biamp/preset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: text + }); + if (!response.ok) throw new Error('Import failed'); + alert('Preset imported successfully! Refreshing...'); + location.reload(); + } catch (err) { + alert('Failed to import preset: ' + err.message); + } + }; + input.click(); + }; + presetButtonsDiv.appendChild(importBtn); + + const resetBtn = document.createElement('button'); + resetBtn.className = 'preset-btn reset-btn'; + resetBtn.textContent = 'Reset to Defaults'; + resetBtn.onclick = async () => { + if (!confirm('Reset all Advanced Bi-Amp settings to defaults?\n\nThis will clear all crossover, PEQ, and loudness settings.')) return; + try { + const response = await fetch(`${getBackendUrl()}/api/biamp/reset`, { method: 'POST' }); + if (!response.ok) throw new Error('Reset failed'); + alert('Settings reset to defaults! Refreshing...'); + location.reload(); + } catch (err) { + alert('Failed to reset settings: ' + err.message); + } + }; + presetButtonsDiv.appendChild(resetBtn); + + sectionsContainer.appendChild(presetButtonsDiv); + + contentWrapper.appendChild(sectionsContainer); + groupDiv.appendChild(contentWrapper); + updateGroupVisibility(groupDiv); + return groupDiv; + } + // Normal parameters if (group.parameters) { group.parameters.forEach(param => { const controlDiv = settingsUI.renderParameter(param, currentSettings, async (k, v) => { await updateSetting(k, v); }); + if (param.visibleWhen) { + controlDiv.setAttribute('data-visible-when', JSON.stringify(param.visibleWhen)); + if (!settingsUI.isParameterVisible(param, currentSettings)) { + controlDiv.style.display = 'none'; + } + } contentWrapper.appendChild(controlDiv); }); } @@ -549,56 +1068,103 @@

EQ Settings

settingsUI.updateAllGroupVisibility(currentSettings); } - // Update a setting + // Flush any pending debounced updates (called on page unload) + function flushPendingUpdates() { + Object.keys(debounceTimers).forEach(key => { + if (debounceTimers[key]) { + clearTimeout(debounceTimers[key].timerId); + // Send the pending update synchronously + const { value } = debounceTimers[key]; + navigator.sendBeacon(`${getBackendUrl()}/api/eq/settings`, JSON.stringify({ [key]: value })); + delete debounceTimers[key]; + } + }); + } + + // Flush pending updates before page unload + window.addEventListener('beforeunload', flushPendingUpdates); + window.addEventListener('pagehide', flushPendingUpdates); + + // Update a setting with debouncing (300ms delay to match DSP/DAC pages) async function updateSetting(key, value) { - try { + // Update local state immediately for responsive UI + currentSettings[key] = value; + + // Check if this is a mode change - these should happen immediately + const isModeChange = (key === 'eq_ui_mode'); + + if (isModeChange) { + // Mode changes happen immediately without debounce + try { const update = { [key]: value }; - console.log(`Updating ${key} = ${value}`); + console.log(`Updating ${key} = ${value} (immediate)`); await postRequest('/api/eq/settings', update); - - // Check if this is a mode change or gain/preset change BEFORE updating local state - const isModeChange = (key === 'eq_ui_mode'); - const currentMode = currentSettings.eq_ui_mode; - const isAutoMode = (currentMode === 1 || currentMode === 2 || currentMode === 3); // 15-band, 15-band biamp, or preset - const isGainOrPresetChange = key.startsWith('eq_gain_') || key.startsWith('eq_profile_'); - - // Update local state after checking - currentSettings[key] = value; - - if (isModeChange) { - // Mode change - always reload schema and settings - console.log('Mode changed, reloading schema and settings...'); - const backendUrl = getBackendUrl(); - const [schemaResponse, settingsResponse] = await Promise.all([ - fetch(`${backendUrl}/api/eq/schema`), - fetch(`${backendUrl}/api/eq/settings`) - ]); - if (schemaResponse.ok && settingsResponse.ok) { - schema = await schemaResponse.json(); + + // Mode change - reload schema and settings (staged to avoid ESP32 socket/memory issues) + console.log('Mode changed, reloading schema and settings...'); + const backendUrl = getBackendUrl(); + const schemaResponse = await fetch(`${backendUrl}/api/eq/schema`); + if (schemaResponse.ok) { + schema = await schemaResponse.json(); + await new Promise(resolve => setTimeout(resolve, 300)); + const settingsResponse = await fetch(`${backendUrl}/api/eq/settings`); + if (settingsResponse.ok) { currentSettings = await settingsResponse.json(); renderUI(); } else { updateAllGroupVisibility(); } - } else if (isAutoMode && isGainOrPresetChange) { - // In auto modes, when gains or presets change, the DAC calculates new BQ coefficients - // Reload both schema and settings to get the actual coefficients that were applied to the DAC - console.log('Reloading schema and settings to fetch actual DAC coefficients...'); - const backendUrl = getBackendUrl(); - const [schemaResponse, settingsResponse] = await Promise.all([ - fetch(`${backendUrl}/api/eq/schema`), - fetch(`${backendUrl}/api/eq/settings`) - ]); - if (schemaResponse.ok && settingsResponse.ok) { - schema = await schemaResponse.json(); - currentSettings = await settingsResponse.json(); - renderUI(); // Re-render to show updated coefficients - } + } else { + updateAllGroupVisibility(); } - } catch (error) { - console.error('Error updating setting:', error); - alert(`Error updating setting: ${error.message}`); + } catch (error) { + console.error('Error updating setting:', error); + alert(`Error updating setting: ${error.message}`); + } + return; + } + + // Debounce: clear any pending update for this parameter + if (debounceTimers[key]) { + clearTimeout(debounceTimers[key].timerId); } + + // Store the pending value and set a new timer + debounceTimers[key] = { + value: value, + timerId: setTimeout(async () => { + try { + const update = { [key]: value }; + console.log(`Updating ${key} = ${value} (debounced)`); + await postRequest('/api/eq/settings', update); + + // Check if we need to reload for coefficient updates + const currentMode = currentSettings.eq_ui_mode; + const isAutoMode = (currentMode === 1 || currentMode === 2 || currentMode === 3); + const isGainOrPresetChange = key.startsWith('eq_gain_') || key.startsWith('eq_profile_'); + + if (isAutoMode && isGainOrPresetChange) { + // In auto modes, reload to get actual DAC coefficients (staged fetch) + console.log('Reloading schema and settings to fetch actual DAC coefficients...'); + const backendUrl = getBackendUrl(); + const schemaResponse = await fetch(`${backendUrl}/api/eq/schema`); + if (schemaResponse.ok) { + schema = await schemaResponse.json(); + await new Promise(resolve => setTimeout(resolve, 300)); + const settingsResponse = await fetch(`${backendUrl}/api/eq/settings`); + if (settingsResponse.ok) { + currentSettings = await settingsResponse.json(); + renderUI(); + } + } + } + } catch (error) { + console.error('Error updating setting:', error); + alert(`Error updating setting: ${error.message}`); + } + delete debounceTimers[key]; + }, 300) // 300ms debounce delay + }; } // Load settings on page load diff --git a/components/ui_http_server/html/index.html b/components/ui_http_server/html/index.html index eb9e7634..9725b15e 100644 --- a/components/ui_http_server/html/index.html +++ b/components/ui_http_server/html/index.html @@ -82,6 +82,63 @@ border: none; background-color: white; } + + /* Mobile responsive navigation */ + @media (max-width: 768px) { + .nav-bar { + flex-direction: column; + align-items: stretch; + } + + .nav-bar h1 { + border-right: none; + border-bottom: 1px solid #34495e; + font-size: 18px; + padding: 12px 15px; + text-align: center; + } + + .nav-tabs { + flex-wrap: wrap; + width: 100%; + } + + .nav-tabs li { + flex: 1 1 50%; + } + + .nav-tabs a { + padding: 12px 15px; + border-right: none; + border-bottom: 1px solid #34495e; + text-align: center; + } + + .nav-tabs li:nth-child(odd) a { + border-right: 1px solid #34495e; + } + } + + /* Extra small mobile */ + @media (max-width: 480px) { + .nav-bar h1 { + font-size: 16px; + padding: 10px 12px; + } + + .nav-tabs li { + flex: 1 1 100%; + } + + .nav-tabs a { + padding: 10px 12px; + font-size: 14px; + } + + .nav-tabs li:nth-child(odd) a { + border-right: none; + } + } diff --git a/components/ui_http_server/html/settings-ui.js b/components/ui_http_server/html/settings-ui.js index a09fc0c0..062d794a 100644 --- a/components/ui_http_server/html/settings-ui.js +++ b/components/ui_http_server/html/settings-ui.js @@ -52,6 +52,39 @@ return controlDiv; } + if (param.type === 'radio') { + const label = document.createElement('label'); + label.className = 'radio-group-label'; + label.textContent = param.name; + controlDiv.appendChild(label); + + const radioGroup = document.createElement('div'); + radioGroup.className = 'radio-group'; + param.values.forEach(option => { + const radioLabel = document.createElement('label'); + radioLabel.className = 'radio-option'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = param.key; + radio.value = option.value; + if (value == option.value) radio.checked = true; + if (param.readonly) radio.disabled = true; + radio.onchange = function () { + if (onChange) onChange(param.key, parseInt(this.value)); + }; + + const span = document.createElement('span'); + span.textContent = option.name; + + radioLabel.appendChild(radio); + radioLabel.appendChild(span); + radioGroup.appendChild(radioLabel); + }); + controlDiv.appendChild(radioGroup); + return controlDiv; + } + if (param.type === 'range') { const info = document.createElement('div'); info.className = 'param-info'; @@ -171,13 +204,241 @@ return sliderDiv; } + // Check if a parameter should be visible based on visibleWhen condition + function isParameterVisible(param, currentSettings) { + if (!param.visibleWhen) return true; + for (const [key, expectedValue] of Object.entries(param.visibleWhen)) { + const actualValue = currentSettings[key]; + if (actualValue !== expectedValue) return false; + } + return true; + } + + // Update visibility of parameters with visibleWhen conditions + function updateConditionalVisibility(container, currentSettings) { + container.querySelectorAll('[data-visible-when]').forEach(el => { + try { + const condition = JSON.parse(el.getAttribute('data-visible-when')); + let visible = true; + for (const [key, expectedValue] of Object.entries(condition)) { + if (currentSettings[key] !== expectedValue) { + visible = false; + break; + } + } + el.style.display = visible ? '' : 'none'; + } catch (e) { + console.error('Error parsing visibleWhen:', e); + } + }); + } + + // Render a PEQ subgroup (freq, gain, Q grouped together) + function renderPeqSubgroup(subgroup, currentSettings, onChange) { + const groupDiv = document.createElement('div'); + groupDiv.className = 'peq-subgroup'; + + const header = document.createElement('div'); + header.className = 'peq-subgroup-header'; + header.textContent = subgroup.name; + groupDiv.appendChild(header); + + const paramsRow = document.createElement('div'); + paramsRow.className = 'peq-subgroup-params'; + + subgroup.parameters.forEach(param => { + const controlDiv = document.createElement('div'); + controlDiv.className = 'peq-param'; + + const label = document.createElement('label'); + label.textContent = param.name; + controlDiv.appendChild(label); + + if (param.type === 'enum') { + // Render enum as dropdown + const select = document.createElement('select'); + param.values.forEach(option => { + const opt = document.createElement('option'); + opt.value = option.value; + opt.textContent = option.name; + if (param.current == option.value) opt.selected = true; + select.appendChild(opt); + }); + select.onchange = function () { + if (onChange) onChange(param.key, parseInt(this.value)); + }; + controlDiv.appendChild(select); + } else { + // Render as range slider (default) + const input = document.createElement('input'); + input.type = 'range'; + input.min = param.min; + input.max = param.max; + input.step = param.step || 1; + input.value = param.current !== undefined ? param.current : param.default || param.min; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'peq-param-value'; + const decimals = param.decimals !== undefined ? param.decimals : 0; + valueSpan.textContent = Number(input.value).toFixed(decimals) + (param.unit || ''); + + input.oninput = function () { + valueSpan.textContent = Number(this.value).toFixed(decimals) + (param.unit || ''); + }; + input.onchange = function () { + if (onChange) onChange(param.key, parseFloat(this.value)); + }; + + controlDiv.appendChild(input); + controlDiv.appendChild(valueSpan); + } + paramsRow.appendChild(controlDiv); + }); + + groupDiv.appendChild(paramsRow); + return groupDiv; + } + + // Render a loudness zone subgroup (threshold, bass, treble grouped together) + function renderLoudnessZone(subgroup, currentSettings, onChange) { + const groupDiv = document.createElement('div'); + groupDiv.className = 'loudness-zone'; + + const header = document.createElement('div'); + header.className = 'loudness-zone-header'; + header.textContent = subgroup.name; + groupDiv.appendChild(header); + + const paramsRow = document.createElement('div'); + paramsRow.className = 'loudness-zone-params'; + + subgroup.parameters.forEach(param => { + const controlDiv = document.createElement('div'); + controlDiv.className = 'loudness-zone-param'; + + const label = document.createElement('label'); + label.textContent = param.name; + controlDiv.appendChild(label); + + const input = document.createElement('input'); + input.type = 'range'; + input.min = param.min; + input.max = param.max; + input.step = param.step || 1; + input.value = param.current !== undefined ? param.current : param.default || param.min; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'loudness-zone-value'; + const decimals = param.decimals !== undefined ? param.decimals : 0; + valueSpan.textContent = Number(input.value).toFixed(decimals) + (param.unit || ''); + + input.oninput = function () { + valueSpan.textContent = Number(this.value).toFixed(decimals) + (param.unit || ''); + }; + input.onchange = function () { + if (onChange) onChange(param.key, parseFloat(this.value)); + }; + + controlDiv.appendChild(input); + controlDiv.appendChild(valueSpan); + paramsRow.appendChild(controlDiv); + }); + + groupDiv.appendChild(paramsRow); + return groupDiv; + } + + // Render a section within a group (for biamp Low/High columns) + function renderSection(section, currentSettings, onChange) { + const sectionDiv = document.createElement('div'); + sectionDiv.className = 'biamp-section'; + if (section.layout) { + sectionDiv.classList.add('section-' + section.layout); + } + + if (section.name) { + const title = document.createElement('h3'); + title.className = 'section-title'; + title.textContent = section.name; + sectionDiv.appendChild(title); + } + + const paramsContainer = document.createElement('div'); + paramsContainer.className = 'section-params'; + + if (section.layout === 'vertical-sliders') { + paramsContainer.classList.add('vertical-sliders-container'); + section.parameters.forEach(param => { + // Use vertical slider only for range type, otherwise use standard renderer + const controlDiv = (param.type === 'range') + ? renderVerticalSlider(param, currentSettings, onChange) + : renderParameter(param, currentSettings, onChange); + if (param.visibleWhen) { + controlDiv.setAttribute('data-visible-when', JSON.stringify(param.visibleWhen)); + if (!isParameterVisible(param, currentSettings)) { + controlDiv.style.display = 'none'; + } + } + paramsContainer.appendChild(controlDiv); + }); + } else if (section.layout === 'output-channel') { + // Output channel layout: regular params + PEQ subgroups + generic subgroups + phase radio + section.parameters.forEach(param => { + let controlDiv; + if (param.type === 'peq-subgroup' || param.type === 'subgroup') { + controlDiv = renderPeqSubgroup(param, currentSettings, onChange); + } else { + controlDiv = renderParameter(param, currentSettings, onChange); + } + if (param.visibleWhen) { + controlDiv.setAttribute('data-visible-when', JSON.stringify(param.visibleWhen)); + if (!isParameterVisible(param, currentSettings)) { + controlDiv.style.display = 'none'; + } + } + paramsContainer.appendChild(controlDiv); + }); + } else if (section.layout === 'loudness') { + // Loudness layout: enable toggle + loudness-zone subgroups + section.parameters.forEach(param => { + let controlDiv; + if (param.type === 'loudness-zone') { + controlDiv = renderLoudnessZone(param, currentSettings, onChange); + } else { + controlDiv = renderParameter(param, currentSettings, onChange); + } + if (param.visibleWhen) { + controlDiv.setAttribute('data-visible-when', JSON.stringify(param.visibleWhen)); + if (!isParameterVisible(param, currentSettings)) { + controlDiv.style.display = 'none'; + } + } + paramsContainer.appendChild(controlDiv); + }); + } else { + section.parameters.forEach(param => { + const controlDiv = renderParameter(param, currentSettings, onChange); + if (param.visibleWhen) { + controlDiv.setAttribute('data-visible-when', JSON.stringify(param.visibleWhen)); + if (!isParameterVisible(param, currentSettings)) { + controlDiv.style.display = 'none'; + } + } + paramsContainer.appendChild(controlDiv); + }); + } + + sectionDiv.appendChild(paramsContainer); + return sectionDiv; + } + // Visibility helpers for EQ groups function updateGroupVisibility(groupDiv, currentSettings) { const layout = groupDiv.getAttribute('data-layout'); const channel = groupDiv.getAttribute('data-channel'); if (!layout) return; const uiMode = (currentSettings && currentSettings.eq_ui_mode !== undefined) ? currentSettings.eq_ui_mode : 0; - + if (layout === 'eq-bands') { if (uiMode === 1) { groupDiv.style.display = (channel === 'left') ? 'block' : 'none'; @@ -187,18 +448,18 @@ groupDiv.style.display = 'none'; } } - + if (layout === 'biquad-manual') { // Always show biquad section, but expand/collapse based on mode groupDiv.style.display = 'block'; const isManualMode = (uiMode === 4); - + if (isManualMode) { groupDiv.classList.remove('collapsed'); } else { groupDiv.classList.add('collapsed'); } - + // Update readonly state of inputs when mode changes const inputs = groupDiv.querySelectorAll('input[type="number"]'); inputs.forEach(input => { @@ -209,7 +470,7 @@ input.disabled = !isManualMode; } }); - + // Update button group: always visible, but Apply button only enabled in manual mode const buttonGroup = groupDiv.querySelector('.button-group'); if (buttonGroup) { @@ -220,10 +481,17 @@ } } } - + if (layout === 'eq-presets') { groupDiv.style.display = (uiMode === 3) ? 'block' : 'none'; } + + if (layout === 'biamp-crossover') { + groupDiv.style.display = (uiMode === 4) ? 'block' : 'none'; + } + + // Update conditional visibility within the group + updateConditionalVisibility(groupDiv, currentSettings); } function updateAllGroupVisibility(currentSettings) { @@ -238,6 +506,11 @@ debounce, renderParameter, renderVerticalSlider, + renderPeqSubgroup, + renderLoudnessZone, + renderSection, + isParameterVisible, + updateConditionalVisibility, updateGroupVisibility, updateAllGroupVisibility, }; diff --git a/components/ui_http_server/html/styles.css b/components/ui_http_server/html/styles.css index 0f32552e..95cb78f3 100644 --- a/components/ui_http_server/html/styles.css +++ b/components/ui_http_server/html/styles.css @@ -121,10 +121,65 @@ textarea:focus { @media (max-width: 768px) { body { padding: 10px; + margin: 10px auto; } - + .btn { padding: 8px 16px; font-size: 13px; } + + h1 { + font-size: 22px; + } + + h2 { + font-size: 18px; + } + + /* Make button groups stack on mobile */ + .button-group { + flex-direction: column; + } + + .button-group .btn { + width: 100%; + } + + /* Better touch targets */ + input[type="text"], + input[type="number"], + select { + font-size: 16px; /* Prevents iOS zoom on focus */ + padding: 12px; + } +} + +@media (max-width: 480px) { + body { + padding: 8px; + margin: 5px; + } + + h1 { + font-size: 18px; + } + + h2 { + font-size: 16px; + } + + .btn { + padding: 10px 12px; + font-size: 14px; + } + + .loading, + .error, + .success, + .warning, + .info { + padding: 10px; + font-size: 14px; + } } \ No newline at end of file diff --git a/components/ui_http_server/ui_http_server.c b/components/ui_http_server/ui_http_server.c index b616a700..d86ab337 100644 --- a/components/ui_http_server/ui_http_server.c +++ b/components/ui_http_server/ui_http_server.c @@ -10,6 +10,7 @@ #include "ui_http_server.h" +#include #include #include @@ -30,6 +31,8 @@ static const char *TAG = "UI_HTTP"; +#define MAX_POST_BODY_SIZE (32 * 1024) /* 32 KB max for POST request bodies */ + static QueueHandle_t xQueueHttp = NULL; static TaskHandle_t taskHandle = NULL; static httpd_handle_t server = NULL; @@ -75,6 +78,13 @@ static const embedded_file_t embedded_files[] = { {"/favicon.ico", favicon_ico_start, favicon_ico_end, "image/x-icon"}, }; +/** + * Check if a character is a valid hexadecimal digit + */ +static inline int is_hex_digit(char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); +} + /** * Simple URL decode function * Decodes %XX hex sequences and + as space @@ -85,8 +95,9 @@ static void url_decode(char *dst, const char *src, size_t dst_size) { while (src[src_idx] != '\0' && dst_idx < dst_size - 1) { if (src[src_idx] == '%' && src[src_idx + 1] != '\0' && - src[src_idx + 2] != '\0') { - // Decode %XX + src[src_idx + 2] != '\0' && + is_hex_digit(src[src_idx + 1]) && is_hex_digit(src[src_idx + 2])) { + // Decode %XX (only if both chars are valid hex digits) char hex[3] = {src[src_idx + 1], src[src_idx + 2], '\0'}; dst[dst_idx++] = (char)strtol(hex, NULL, 16); src_idx += 3; @@ -94,6 +105,9 @@ static void url_decode(char *dst, const char *src, size_t dst_size) { // Convert + to space dst[dst_idx++] = ' '; src_idx++; + } else if (src[src_idx] == '%') { + // Invalid %XX sequence - skip the % and continue + src_idx++; } else { dst[dst_idx++] = src[src_idx++]; } @@ -103,10 +117,17 @@ static void url_decode(char *dst, const char *src, size_t dst_size) { /** * Find key value in parameter string + * @param key Key to search for (including '=' suffix) + * @param parameter Parameter string to search in + * @param value Output buffer for the value + * @param value_size Size of the output buffer + * @return Length of value found, 0 if not found */ -static int find_key_value(char *key, char *parameter, char *value) { +static int find_key_value(char *key, char *parameter, char *value, size_t value_size) { + if (value_size == 0) return 0; + value[0] = '\0'; + ESP_LOGD(TAG, "%s: key=%s", __func__, key); - // char * addr1; char *addr1 = strstr(parameter, key); if (addr1 == NULL) return 0; @@ -117,25 +138,80 @@ static int find_key_value(char *key, char *parameter, char *value) { char *addr3 = strstr(addr2, "&"); ESP_LOGD(TAG, "%s: addr3=%p", __func__, addr3); + + size_t length; if (addr3 == NULL) { - strcpy(value, addr2); + length = strlen(addr2); } else { - int length = addr3 - addr2; - ESP_LOGD(TAG, "%s: addr2=%p addr3=%p length=%d", __func__, addr2, addr3, - length); - strncpy(value, addr2, length); - value[length] = 0; + length = addr3 - addr2; + } + + /* Bound the copy to the buffer size (leave room for null terminator) */ + if (length >= value_size) { + length = value_size - 1; + ESP_LOGW(TAG, "%s: value truncated to %zu chars", __func__, length); } + + ESP_LOGD(TAG, "%s: addr2=%p addr3=%p length=%zu", __func__, addr2, addr3, length); + memcpy(value, addr2, length); + value[length] = '\0'; + ESP_LOGD(TAG, "%s: key=[%s] value=[%s]", __func__, key, value); return strlen(value); } +/** + * Validate that an Origin header represents a local/private network address. + * Checks for localhost and RFC1918 private IP ranges, ensuring the character + * after the prefix is valid (digit for IPs, terminator for localhost) to + * prevent hostname spoofing like "http://192.168.evil.com". + */ +static bool is_local_origin(const char *origin) { + /* Check localhost variants */ + if (strncmp(origin, "http://localhost", 16) == 0) { + char after = origin[16]; + return (after == '\0' || after == ':' || after == '/'); + } + if (strncmp(origin, "https://localhost", 17) == 0) { + char after = origin[17]; + return (after == '\0' || after == ':' || after == '/'); + } + /* Check private IP ranges - next char after prefix must be a digit */ + if (strncmp(origin, "http://127.", 11) == 0) { + return isdigit((unsigned char)origin[11]); + } + if (strncmp(origin, "http://192.168.", 15) == 0) { + return isdigit((unsigned char)origin[15]); + } + if (strncmp(origin, "http://10.", 10) == 0) { + return isdigit((unsigned char)origin[10]); + } + /* 172.16-31.x.x range (RFC1918) */ + if (strncmp(origin, "http://172.", 11) == 0 && isdigit((unsigned char)origin[11])) { + int second_octet = atoi(&origin[11]); + return (second_octet >= 16 && second_octet <= 31); + } + return false; +} + /** * Set CORS headers to allow cross-origin requests * This enables local development with ?backend parameter + * + * Security note: Instead of wildcard (*), we reflect the request's Origin header. + * This prevents arbitrary websites from making cross-origin requests while still + * allowing legitimate local development scenarios (localhost, local IPs). + * The device has no authentication, so CORS is the main CSRF protection. */ static void set_cors_headers(httpd_req_t *req) { - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + /* Get the Origin header from the request */ + char origin[128] = {0}; + if (httpd_req_get_hdr_value_str(req, "Origin", origin, sizeof(origin)) == ESP_OK && origin[0] != '\0') { + if (is_local_origin(origin)) { + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", origin); + } + /* If origin doesn't match allowed patterns, don't set CORS header (browser will block) */ + } httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); @@ -174,8 +250,8 @@ static esp_err_t root_post_handler(httpd_req_t *req) { memset(&urlBuf, 0, sizeof(URL_t)); - if (find_key_value("param=", (char *)req->uri, param) && - find_key_value("value=", (char *)req->uri, valstr)) { + if (find_key_value("param=", (char *)req->uri, param, sizeof(param)) && + find_key_value("value=", (char *)req->uri, valstr, sizeof(valstr))) { // Special handling for hostname (string parameter) if (strcmp(param, "hostname") == 0) { @@ -281,7 +357,7 @@ static esp_err_t root_delete_handler(httpd_req_t *req) { set_cors_headers(req); - if (!find_key_value("param=", (char *)req->uri, param)) { + if (!find_key_value("param=", (char *)req->uri, param, sizeof(param))) { ESP_LOGD(TAG, "%s: Invalid delete: expected param=NAME in URI", __func__); httpd_resp_set_status(req, "400 Bad Request"); @@ -360,7 +436,7 @@ static esp_err_t get_param_handler(httpd_req_t *req) { set_cors_headers(req); - if (find_key_value("param=", (char *)req->uri, param)) { + if (find_key_value("param=", (char *)req->uri, param, sizeof(param))) { // Special handling for hostname (string parameter) if (strcmp(param, "hostname") == 0) { char hostname[64] = {0}; @@ -516,7 +592,7 @@ static esp_err_t get_capabilities_handler(httpd_req_t *req) { // Parse tab parameter char tab[16] = {0}; - if (!find_key_value("tab=", (char *)req->uri, tab)) { + if (!find_key_value("tab=", (char *)req->uri, tab, sizeof(tab))) { // No tab specified, return error ESP_LOGW(TAG, "%s: Missing 'tab' parameter", __func__); httpd_resp_set_status(req, "400 Bad Request"); @@ -711,9 +787,10 @@ static esp_err_t get_dac_schema_handler(httpd_req_t *req) { httpd_resp_set_status(req, "200 OK"); httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Connection", "close"); // Free socket after large response httpd_resp_sendstr(req, schema_json); free(schema_json); - + return ESP_OK; #else httpd_resp_set_status(req, "404 Not Found"); @@ -722,16 +799,52 @@ static esp_err_t get_dac_schema_handler(httpd_req_t *req) { #endif } +/** + * Read the complete request body, handling partial reads. + * Returns total bytes read, or negative value on error. + */ +static int httpd_recv_all(httpd_req_t *req, char *buf, size_t len) { + int total = 0; + int retries = 0; + while (total < (int)len) { + int ret = httpd_req_recv(req, buf + total, len - total); + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + if (++retries >= 5) { + return HTTPD_SOCK_ERR_TIMEOUT; + } + continue; + } + if (ret <= 0) { + return ret; + } + total += ret; + retries = 0; + } + return total; +} + /* * POST /api/dac/settings handler * Updates TAS5805M DAC settings from JSON */ static esp_err_t post_dac_settings_handler(httpd_req_t *req) { ESP_LOGD(TAG, "%s: uri=%s", __func__, req->uri); - + set_cors_headers(req); - + #if CONFIG_DAC_TAS5805M + if (req->content_len == 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "{\"error\": \"Empty request body\"}"); + return ESP_OK; + } + if (req->content_len > MAX_POST_BODY_SIZE) { + ESP_LOGW(TAG, "%s: Request body too large (%d bytes)", __func__, req->content_len); + httpd_resp_set_status(req, "413 Payload Too Large"); + httpd_resp_sendstr(req, "{\"error\": \"Request body too large\"}"); + return ESP_OK; + } + // Allocate buffer for request body char *buf = (char *)malloc(req->content_len + 1); if (!buf) { @@ -740,9 +853,9 @@ static esp_err_t post_dac_settings_handler(httpd_req_t *req) { httpd_resp_sendstr(req, "{\"error\": \"Memory allocation failed\"}"); return ESP_OK; } - + // Read request body - int ret = httpd_req_recv(req, buf, req->content_len); + int ret = httpd_recv_all(req, buf, req->content_len); if (ret <= 0) { free(buf); if (ret == HTTPD_SOCK_ERR_TIMEOUT) { @@ -811,9 +924,10 @@ static esp_err_t get_eq_settings_handler(httpd_req_t *req) { httpd_resp_set_status(req, "200 OK"); httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Connection", "close"); // Free socket after 16KB response httpd_resp_sendstr(req, eq_json); free(eq_json); - + return ESP_OK; #else httpd_resp_set_status(req, "404 Not Found"); @@ -828,12 +942,12 @@ static esp_err_t get_eq_settings_handler(httpd_req_t *req) { */ static esp_err_t get_eq_schema_handler(httpd_req_t *req) { ESP_LOGD(TAG, "%s: uri=%s", __func__, req->uri); - + set_cors_headers(req); - + #if CONFIG_DAC_TAS5805M const size_t schema_buf_size = 64 * 1024; // 64KB for EQ schema - + char *schema_json = (char *)malloc(schema_buf_size); if (!schema_json) { ESP_LOGE(TAG, "%s: Failed to allocate memory for EQ schema JSON (size=%zu)", __func__, schema_buf_size); @@ -843,7 +957,7 @@ static esp_err_t get_eq_schema_handler(httpd_req_t *req) { } esp_err_t ret = tas5805m_settings_get_eq_schema_json(schema_json, schema_buf_size); - + if (ret != ESP_OK) { ESP_LOGE(TAG, "%s: Failed to get EQ schema JSON: %s", __func__, esp_err_to_name(ret)); free(schema_json); @@ -851,12 +965,13 @@ static esp_err_t get_eq_schema_handler(httpd_req_t *req) { httpd_resp_sendstr(req, "{\"error\": \"Failed to retrieve EQ schema\"}"); return ESP_OK; } - + httpd_resp_set_status(req, "200 OK"); httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Connection", "close"); // Free socket after large response httpd_resp_sendstr(req, schema_json); free(schema_json); - + return ESP_OK; #else httpd_resp_set_status(req, "404 Not Found"); @@ -871,10 +986,22 @@ static esp_err_t get_eq_schema_handler(httpd_req_t *req) { */ static esp_err_t post_eq_settings_handler(httpd_req_t *req) { ESP_LOGD(TAG, "%s: uri=%s", __func__, req->uri); - + set_cors_headers(req); - + #if CONFIG_DAC_TAS5805M + if (req->content_len == 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "{\"error\": \"Empty request body\"}"); + return ESP_OK; + } + if (req->content_len > MAX_POST_BODY_SIZE) { + ESP_LOGW(TAG, "%s: Request body too large (%d bytes)", __func__, req->content_len); + httpd_resp_set_status(req, "413 Payload Too Large"); + httpd_resp_sendstr(req, "{\"error\": \"Request body too large\"}"); + return ESP_OK; + } + // Allocate buffer for request body char *buf = (char *)malloc(req->content_len + 1); if (!buf) { @@ -883,9 +1010,9 @@ static esp_err_t post_eq_settings_handler(httpd_req_t *req) { httpd_resp_sendstr(req, "{\"error\": \"Memory allocation failed\"}"); return ESP_OK; } - + // Read request body - int ret = httpd_req_recv(req, buf, req->content_len); + int ret = httpd_recv_all(req, buf, req->content_len); if (ret <= 0) { free(buf); if (ret == HTTPD_SOCK_ERR_TIMEOUT) { @@ -924,6 +1051,151 @@ static esp_err_t post_eq_settings_handler(httpd_req_t *req) { #endif } +/* + * GET /api/biamp/preset handler + * Exports current bi-amp settings as a downloadable JSON preset + */ +static esp_err_t get_biamp_preset_handler(httpd_req_t *req) { + ESP_LOGD(TAG, "%s: uri=%s", __func__, req->uri); + + set_cors_headers(req); + +#if CONFIG_DAC_TAS5805M + // Allocate buffer for JSON output + char *json_buf = (char *)malloc(4096); + if (!json_buf) { + ESP_LOGE(TAG, "%s: Failed to allocate buffer", __func__); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Memory allocation failed\"}"); + return ESP_OK; + } + + esp_err_t err = tas5805m_biamp_preset_export(json_buf, 4096); + if (err != ESP_OK) { + free(json_buf); + ESP_LOGE(TAG, "%s: Failed to export preset: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Failed to export preset\"}"); + return ESP_OK; + } + + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"biamp_preset.json\""); + httpd_resp_sendstr(req, json_buf); + free(json_buf); + + return ESP_OK; +#else + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "{\"error\": \"TAS5805M not configured\"}"); + return ESP_OK; +#endif +} + +/* + * POST /api/biamp/preset handler + * Imports a bi-amp preset from JSON + */ +static esp_err_t post_biamp_preset_handler(httpd_req_t *req) { + ESP_LOGD(TAG, "%s: uri=%s", __func__, req->uri); + + set_cors_headers(req); + +#if CONFIG_DAC_TAS5805M + if (req->content_len == 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "{\"error\": \"Empty request body\"}"); + return ESP_OK; + } + if (req->content_len > MAX_POST_BODY_SIZE) { + ESP_LOGW(TAG, "%s: Request body too large (%d bytes)", __func__, req->content_len); + httpd_resp_set_status(req, "413 Payload Too Large"); + httpd_resp_sendstr(req, "{\"error\": \"Request body too large\"}"); + return ESP_OK; + } + + // Allocate buffer for request body + char *buf = (char *)malloc(req->content_len + 1); + if (!buf) { + ESP_LOGE(TAG, "%s: Failed to allocate buffer for request body", __func__); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Memory allocation failed\"}"); + return ESP_OK; + } + + // Read request body + int ret = httpd_recv_all(req, buf, req->content_len); + if (ret <= 0) { + free(buf); + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + httpd_resp_set_status(req, "408 Request Timeout"); + httpd_resp_sendstr(req, "{\"error\": \"Request timeout\"}"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Failed to read request body\"}"); + } + return ESP_OK; + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "%s: Received preset JSON (%d bytes)", __func__, ret); + + // Import the preset + esp_err_t err = tas5805m_biamp_preset_import(buf); + free(buf); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to import preset: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "{\"error\": \"Invalid preset format\"}"); + return ESP_OK; + } + + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\": true}"); + + return ESP_OK; +#else + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "{\"error\": \"TAS5805M not configured\"}"); + return ESP_OK; +#endif +} + +/* + * POST /api/biamp/reset handler + * Resets bi-amp settings to defaults (clears NVS) + */ +static esp_err_t post_biamp_reset_handler(httpd_req_t *req) { + set_cors_headers(req); + +#if defined(CONFIG_DAC_TAS5805M) + ESP_LOGI(TAG, "%s: Resetting bi-amp settings to defaults", __func__); + + esp_err_t err = tas5805m_biamp_reset_defaults(); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: Failed to reset settings: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"error\": \"Failed to reset settings\"}"); + return ESP_OK; + } + + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\": true}"); + + return ESP_OK; +#else + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_sendstr(req, "{\"error\": \"TAS5805M not configured\"}"); + return ESP_OK; +#endif +} + /* * Static file handler * Serves files from embedded flash memory @@ -992,9 +1264,13 @@ esp_err_t start_server(const char *base_path, int port) { ESP_LOGD(TAG, "%s: base_path=%s port=%d", __func__, base_path, port); httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = port; - config.max_open_sockets = 7; + config.max_open_sockets = 7; // Max allowed by LWIP_MAX_SOCKETS config config.max_uri_handlers = 64; - config.lru_purge_enable = true; // Enable LRU socket purging + config.lru_purge_enable = true; // Enable LRU socket purging + config.stack_size = 8192; // Increased for bi-amp schema generation + config.recv_wait_timeout = 5; // 5 second receive timeout (faster cleanup of idle connections) + config.send_wait_timeout = 5; // 5 second send timeout + config.backlog_conn = 10; // Increase connection backlog queue /* Enable wildcard URI matching for static file handler */ config.uri_match_fn = httpd_uri_match_wildcard; @@ -1175,6 +1451,45 @@ esp_err_t start_server(const char *base_path, int port) { .handler = options_handler, }; httpd_register_uri_handler(server, &_options_eq_schema_handler); + + /* URI handlers for Bi-Amp Preset API */ + httpd_uri_t _get_biamp_preset_handler = { + .uri = "/api/biamp/preset", + .method = HTTP_GET, + .handler = get_biamp_preset_handler, + }; + httpd_register_uri_handler(server, &_get_biamp_preset_handler); + + httpd_uri_t _post_biamp_preset_handler = { + .uri = "/api/biamp/preset", + .method = HTTP_POST, + .handler = post_biamp_preset_handler, + }; + httpd_register_uri_handler(server, &_post_biamp_preset_handler); + + /* OPTIONS handler for CORS preflight - Bi-Amp preset endpoint */ + httpd_uri_t _options_biamp_preset_handler = { + .uri = "/api/biamp/preset", + .method = HTTP_OPTIONS, + .handler = options_handler, + }; + httpd_register_uri_handler(server, &_options_biamp_preset_handler); + + /* URI handler for Bi-Amp Reset to Defaults */ + httpd_uri_t _post_biamp_reset_handler = { + .uri = "/api/biamp/reset", + .method = HTTP_POST, + .handler = post_biamp_reset_handler, + }; + httpd_register_uri_handler(server, &_post_biamp_reset_handler); + + /* OPTIONS handler for CORS preflight - Bi-Amp reset endpoint */ + httpd_uri_t _options_biamp_reset_handler = { + .uri = "/api/biamp/reset", + .method = HTTP_OPTIONS, + .handler = options_handler, + }; + httpd_register_uri_handler(server, &_options_biamp_reset_handler); #endif /* CONFIG_DAC_TAS5805M */ /* URI handler for static files (catch-all, must be last) */ diff --git a/docs/ADVANCED_BIAMP_USER_GUIDE.md b/docs/ADVANCED_BIAMP_USER_GUIDE.md new file mode 100644 index 00000000..d2bc2c8a --- /dev/null +++ b/docs/ADVANCED_BIAMP_USER_GUIDE.md @@ -0,0 +1,385 @@ +# Advanced Bi-Amp Crossover User Guide + +This guide covers all configuration options available in the Advanced Bi-Amp mode for the TAS5805M DAC. This mode enables active crossover functionality, allowing a single stereo amplifier to drive a 2-way speaker with separate woofer and tweeter outputs. + +## Overview + +In Advanced Bi-Amp mode: +- **Left channel** → Low-pass filtered → **Woofer** +- **Right channel** → High-pass filtered → **Tweeter** + +The system provides 15 biquad filter stages per channel, allocated as follows: + +| Band | Woofer (Left) | Tweeter (Right) | +|------|---------------|-----------------| +| 0 | Gain/Phase | Gain/Phase | +| 1 | Subsonic HPF | Time Alignment | +| 2-5 | Lowpass Crossover | Highpass Crossover | +| 6-11 | PEQ (6 bands) | PEQ (6 bands) | +| 12 | Baffle Step | Breakup Notch | +| 13 | Bass Loudness | Air/Brilliance | +| 14 | (Spare) | Treble Loudness | + +--- + +## Crossover Settings + +### Frequency +- **Range:** 20 - 20,000 Hz +- **Default:** 2000 Hz +- **Purpose:** Sets the crossover point where the woofer and tweeter frequency ranges meet. + +**Guidelines:** +- Check your speaker/driver specifications for recommended crossover frequency +- Typical 2-way speakers: 1500-3500 Hz +- Small woofers (3-4"): 3000-4000 Hz +- Larger woofers (5-6.5"): 1800-2500 Hz +- Always cross over above the tweeter's resonance frequency (Fs) + +### Slope +- **Options:** 12 dB/oct, 24 dB/oct (LR), 48 dB/oct +- **Default:** 24 dB/oct + +| Slope | Biquads Used | Characteristics | +|-------|--------------|-----------------| +| 12 dB/oct | 1 | Gentle rolloff, wide overlap between drivers | +| 24 dB/oct | 2 | Standard Linkwitz-Riley, flat summed response | +| 48 dB/oct | 4 | Steep rolloff, minimal driver overlap | + +**Recommendation:** 24 dB/oct (Linkwitz-Riley) is the most commonly used and provides excellent phase coherence at the crossover point. + +### Type +- **Options:** Butterworth, Linkwitz-Riley +- **Default:** Linkwitz-Riley + +| Type | Characteristics | +|------|-----------------| +| Butterworth | -3dB at crossover frequency, +3dB summed response bump | +| Linkwitz-Riley | -6dB at crossover frequency, flat summed response | + +**Recommendation:** Linkwitz-Riley is preferred for most applications as it provides a flat combined response. + +### Sample Rate +- **Options:** 44.1 kHz, 48 kHz, 88.2 kHz, 96 kHz +- **Default:** 48 kHz +- **Purpose:** Must match the audio source sample rate for accurate filter calculations. + +**Note:** Mismatched sample rates will cause incorrect crossover frequencies. + +--- + +## Low Output (Woofer) Settings + +### Output Gain +- **Range:** -24 to +24 dB (0.5 dB steps) +- **Default:** 0 dB +- **Purpose:** Adjusts the woofer output level relative to the tweeter. + +**Usage:** +- Use to balance woofer and tweeter levels +- Negative values reduce woofer output +- Typically adjusted by ear or with measurement microphone + +### Subsonic HPF (High-Pass Filter) +- **Range:** 0 - 80 Hz (5 Hz steps) +- **Default:** 0 (disabled) +- **Purpose:** Protects the woofer from ultra-low frequencies that cause excessive excursion. + +**Guidelines:** +- Set to just below the woofer's usable low-frequency limit +- Sealed boxes: typically 30-50 Hz +- Ported boxes: set at or slightly below port tuning frequency +- Prevents wasted amplifier power on inaudible frequencies + +### Baffle Step Compensation + +Baffle step is an acoustic phenomenon where low frequencies diffract around the speaker cabinet while high frequencies beam forward, causing a 6dB level difference. + +#### Baffle Width +- **Range:** 0 - 50 cm (0 = disabled) +- **Purpose:** Enter the width of your speaker's front baffle. + +The system calculates the step frequency using: +``` +Step Frequency = 343 / (π × width_in_meters) +``` + +| Baffle Width | Step Frequency | +|--------------|----------------| +| 15 cm | ~728 Hz | +| 20 cm | ~546 Hz | +| 25 cm | ~437 Hz | +| 30 cm | ~364 Hz | + +#### Placement +- **Options:** Freestanding (+6dB), Near Wall (+3dB), Corner (0dB) +- **Purpose:** Room boundaries provide natural bass reinforcement, reducing the compensation needed. + +| Placement | Compensation | When to Use | +|-----------|--------------|-------------| +| Freestanding | +6 dB | Speaker >1m from any wall | +| Near Wall | +3 dB | Speaker within 0.5m of back wall | +| Corner | 0 dB | Speaker in or near a corner | + +### Parametric EQ (PEQ) - 6 Bands + +Each PEQ band allows precise frequency response correction. + +#### Frequency +- **Range:** 20 - 20,000 Hz (0 = band disabled) +- **Purpose:** Center frequency of the filter. + +#### Gain +- **Range:** -15 to +15 dB (0.5 dB steps) +- **Purpose:** Boost or cut at the center frequency. + +#### Q (Quality Factor) +- **Range:** 0.5 - 10.0 +- **Purpose:** Controls the width of the filter. + +| Q Value | Bandwidth | Use Case | +|---------|-----------|----------| +| 0.5-1.0 | Very wide | Broad tonal shaping | +| 1.0-2.0 | Wide | General EQ adjustments | +| 2.0-5.0 | Medium | Room mode correction | +| 5.0-10.0 | Narrow | Notching specific resonances | + +**Typical Uses:** +- Room mode correction (narrow cuts at problematic frequencies) +- Driver response smoothing +- Voicing adjustments + +### Phase +- **Options:** Normal, Invert +- **Default:** Normal +- **Purpose:** Inverts the polarity of the woofer output. + +**When to Use:** +- If woofer and tweeter are acoustically out of phase at crossover +- Test by ear: correct phase typically gives fuller sound with better imaging +- Some crossover slopes require phase inversion for proper summing + +--- + +## High Output (Tweeter) Settings + +### Output Gain +- **Range:** -24 to +24 dB (0.5 dB steps) +- **Default:** 0 dB +- **Purpose:** Adjusts the tweeter output level relative to the woofer. + +### Tweeter Delay (Time Alignment) +- **Range:** 0 - 50 mm +- **Default:** 0 (disabled) +- **Purpose:** Delays the tweeter signal to align acoustically with the woofer. + +**Why It's Needed:** +The acoustic center of a woofer is typically 15-25mm behind the cone surface, while a tweeter's acoustic center is only 5-10mm behind the dome. If both drivers are flush-mounted, the tweeter's sound arrives first, causing phase issues at the crossover frequency. + +**How to Set:** +1. **Physical Measurement:** Measure the difference between driver acoustic centers +2. **Listening Test:** Adjust until transients sound tight and focused +3. **With Measurement:** Align impulse response peaks in measurement software + +**Conversion:** +- 10mm delay ≈ 29 microseconds +- 20mm delay ≈ 58 microseconds +- 30mm delay ≈ 87 microseconds + +### Breakup Notch + +Most tweeters exhibit a resonance peak at their upper frequency limit (breakup mode). This notch filter tames that peak for smoother response. + +#### Frequency +- **Range:** 0 - 25,000 Hz (0 = disabled) +- **Purpose:** Set to the tweeter's breakup frequency. + +**Finding the Breakup Frequency:** +- Check the tweeter's datasheet/frequency response graph +- Look for a sharp peak near the upper limit +- Typical dome tweeters: 20-28 kHz +- Lower quality tweeters: may be 15-20 kHz + +#### Depth +- **Range:** 0 to -12 dB (0.5 dB steps) +- **Purpose:** Amount of cut at the breakup frequency. + +**Guidelines:** +- Start with -3 to -6 dB +- Match the height of the breakup peak in the frequency response +- Excessive notching can dull the high frequencies + +#### Q (Quality Factor) +- **Range:** 2.0 - 10.0 +- **Default:** 5.0 +- **Purpose:** Width of the notch filter. + +**Guidelines:** +- Higher Q = narrower notch (more surgical) +- Match the width of the breakup peak +- Typical values: 4-8 + +### Air / Brilliance Shelf +- **Range:** -6 to +6 dB (0.5 dB steps) +- **Default:** 0 dB +- **Corner Frequency:** Fixed at 10 kHz +- **Purpose:** Adjusts the overall "air" and sparkle in the treble. + +**Usage:** +| Setting | Effect | +|---------|--------| +| +1 to +3 dB | Adds openness, detail, "air" | +| 0 dB | Neutral | +| -1 to -3 dB | Reduces brightness, less fatigue | + +**When to Adjust:** +- Recording sounds dull → slight boost +- Recording sounds harsh/fatiguing → slight cut +- Personal preference for treble presentation + +### Parametric EQ (PEQ) - 6 Bands + +Same functionality as woofer PEQ. Common tweeter uses: +- Presence adjustment (2-5 kHz) +- Sibilance reduction (5-8 kHz) +- Response smoothing + +### Phase +- **Options:** Normal, Invert +- **Default:** Normal +- **Purpose:** Inverts the polarity of the tweeter output. + +--- + +## Loudness Compensation + +Volume-dependent EQ that boosts bass and treble at lower listening levels, compensating for the ear's reduced sensitivity to frequency extremes at low volumes (Fletcher-Munson curves). + +### Enable/Disable +- **Options:** On, Off +- **Default:** Off + +### Zone Configuration + +The volume range (0-100%) is divided into 5 zones, each with independent bass and treble boost settings. + +#### Thresholds +Define the volume percentage boundaries between zones: +- **Zone 1:** 0% to Threshold 1 +- **Zone 2:** Threshold 1 to Threshold 2 +- **Zone 3:** Threshold 2 to Threshold 3 +- **Zone 4:** Threshold 3 to Threshold 4 +- **Zone 5:** Threshold 4 to 100% + +**Default Thresholds:** 20%, 40%, 60%, 80% + +#### Bass Boost (per zone) +- **Range:** -12 to +12 dB +- **Frequency:** Low shelf at ~200 Hz (woofer channel) + +**Default Values:** +| Zone | Volume Range | Bass Boost | +|------|--------------|------------| +| 1 | 0-20% | +6 dB | +| 2 | 20-40% | +4 dB | +| 3 | 40-60% | +2 dB | +| 4 | 60-80% | +1 dB | +| 5 | 80-100% | 0 dB | + +#### Treble Boost (per zone) +- **Range:** -12 to +12 dB +- **Frequency:** High shelf at ~4 kHz (tweeter channel) + +**Default Values:** +| Zone | Volume Range | Treble Boost | +|------|--------------|--------------| +| 1 | 0-20% | +4 dB | +| 2 | 20-40% | +3 dB | +| 3 | 40-60% | +2 dB | +| 4 | 60-80% | +1 dB | +| 5 | 80-100% | 0 dB | + +--- + +## Preset Export/Import + +Save and share your bi-amp configurations. + +### Export +Creates a JSON file containing all bi-amp settings: +- Crossover configuration +- All woofer settings (gain, subsonic, baffle step, PEQ, phase) +- All tweeter settings (gain, delay, notch, air, PEQ, phase) +- Loudness compensation settings + +### Import +Load a previously exported preset. All settings are validated before applying. + +**Use Cases:** +- Backup your configuration +- Share settings with others using the same speaker design +- Quickly switch between configurations for different speakers + +--- + +## Quick Setup Guide + +### Basic Setup +1. Set **Crossover Frequency** to your speaker's specified crossover point +2. Set **Slope** to 24 dB/oct (Linkwitz-Riley) +3. Set **Sample Rate** to match your audio source +4. Adjust **Woofer Gain** and **Tweeter Gain** to balance levels + +### Adding Protection +1. Set **Subsonic HPF** to protect your woofer (typically 30-50 Hz) +2. If using breakup notch, set frequency from tweeter datasheet + +### Fine-Tuning +1. Add **Baffle Step Compensation** if needed for your cabinet +2. Set **Tweeter Delay** based on physical driver offset +3. Adjust **Phase** if needed for proper driver summing +4. Use **PEQ** bands for room correction or response smoothing +5. Set up **Loudness Compensation** for low-volume listening + +### Final Adjustments +1. Use **Air/Brilliance** shelf for overall treble character +2. Fine-tune gains by ear with music +3. **Export** your preset for backup + +--- + +## Troubleshooting + +### Sound is thin/lacks bass +- Check Subsonic HPF isn't set too high +- Verify Baffle Step Compensation settings +- Check woofer gain and phase settings + +### Harsh or fatiguing treble +- Reduce Air/Brilliance shelf +- Check for and apply Breakup Notch +- Reduce tweeter gain + +### Poor imaging or "hollow" sound at crossover +- Adjust Tweeter Delay for time alignment +- Try inverting phase on one driver +- Verify crossover frequency is appropriate + +### Loudness compensation too aggressive +- Reduce bass/treble boost values in each zone +- Adjust thresholds for smoother transitions + +--- + +## Technical Specifications + +| Parameter | Specification | +|-----------|---------------| +| Filter Type | IIR Biquad (Direct Form I) | +| Coefficient Format | 32-bit floating point (converted to Q5.27) | +| Biquads per Channel | 15 | +| Crossover Slopes | 12/24/48 dB/octave | +| PEQ Bands | 6 per output | +| Gain Resolution | 0.5 dB | +| Frequency Range | 20 Hz - 20 kHz | +| Q Range | 0.5 - 10.0 | diff --git a/partitions.csv b/partitions.csv index 44cbbfb0..0d6dfb8a 100644 --- a/partitions.csv +++ b/partitions.csv @@ -3,4 +3,4 @@ nvs, data, nvs, , 80K, otadata, data, ota, , 8K, phy_init, data, phy, , 4K, ota_0, app, ota_0, , 1984K, -ota_1, app, ota_1, , 1984K, \ No newline at end of file +ota_1, app, ota_1, , 1984K,