diff --git a/components/dsp_processor/Kconfig.projbuild b/components/dsp_processor/Kconfig.projbuild index b273e68b..e1ccf1ba 100644 --- a/components/dsp_processor/Kconfig.projbuild +++ b/components/dsp_processor/Kconfig.projbuild @@ -21,6 +21,19 @@ menu "ESP32 DSP processor config" help Use software volume mixer instead of hardware mixer (when your DAC does not support hardware volume control). + config SNAPCLIENT_VOLUME_CURVE_DB_RANGE + int "Volume curve range in dB (0=linear, 60=standard, 90=max)" + default 60 + depends on USE_DSP_PROCESSOR + range 0 90 + help + Controls the shape of the software volume curve. + 0 = linear passthrough (y = x, no shaping). + >0 = exponential curve y = a * exp(b * x) where + a = 10^(-dB/20), mapping slider [0,1] to amplitude + [10^(-dB/20), 1.0] for perceptually linear loudness. + 60 dB is standard for consumer audio. + config USE_DSP_SOFT_CLIP bool "Use cubic soft clipping" default false diff --git a/components/dsp_processor/dsp_processor.c b/components/dsp_processor/dsp_processor.c index 11d44591..49f6b8f9 100644 --- a/components/dsp_processor/dsp_processor.c +++ b/components/dsp_processor/dsp_processor.c @@ -1,5 +1,6 @@ +#include #include #include #include @@ -48,6 +49,36 @@ static dsp_all_params_t all_params; static ptype_t *filter = NULL; static double dynamic_vol = 1.0; +static double last_slider_position = 1.0; + +// Exponential volume curve: y = curve_a * expf(curve_b * x) +// where curve_a = 10^(-dB_range/20) and curve_b = -log(curve_a). +// When db_range == 0 the curve is bypassed (linear passthrough). +// Near-zero uses a linear ramp up to VOLUME_CURVE_ROLLOFF_THRESHOLD +// to guarantee true silence at slider = 0. +#ifndef CONFIG_SNAPCLIENT_VOLUME_CURVE_DB_RANGE +#define CONFIG_SNAPCLIENT_VOLUME_CURVE_DB_RANGE 60 +#endif +#define VOLUME_CURVE_ROLLOFF_THRESHOLD 0.1f + +static float volume_curve_db_range = (float)CONFIG_SNAPCLIENT_VOLUME_CURVE_DB_RANGE; +static float curve_a; +static float curve_b; +static float curve_threshold_val; + +static void compute_curve(void) { + curve_a = powf(10.0f, -volume_curve_db_range / 20.0f); + curve_b = -logf(curve_a); + curve_threshold_val = curve_a * expf(curve_b * VOLUME_CURVE_ROLLOFF_THRESHOLD); +} + +static double apply_volume_curve(double x) { + if (volume_curve_db_range == 0.0f) + return x; + if (x <= VOLUME_CURVE_ROLLOFF_THRESHOLD) + return x / VOLUME_CURVE_ROLLOFF_THRESHOLD * curve_threshold_val; + return curve_a * expf((float)x * curve_b); +} static bool init = false; @@ -93,6 +124,9 @@ void dsp_processor_init(void) { // dsp_processor_settings component will call dsp_processor_set_params_for_flow() // and dsp_processor_switch_flow() during its init to apply saved settings. + // Use Kconfig default; saved value is loaded by dsp_settings_init() later. + compute_curve(); + if (params_mutex == NULL) { params_mutex = xSemaphoreCreateMutex(); if (params_mutex == NULL) { @@ -778,10 +812,34 @@ int dsp_processor_worker(char *pcmChnk, uint16_t len, uint32_t samplerate, int c void dsp_processor_set_volome(double volume) { ESP_LOGD(TAG, "%s: volume=%f", __func__, volume); if (volume >= 0 && volume <= 1.0) { - ESP_LOGI(TAG, "Set volume to %f", volume); - dynamic_vol = volume; + last_slider_position = volume; + double curve = apply_volume_curve(volume); + ESP_LOGI(TAG, "Set volume to %f (raw %f, range=%.0f dB)", curve, volume, + volume_curve_db_range); + dynamic_vol = curve; } } + +float dsp_processor_get_volume_curve_db_range(void) { + return volume_curve_db_range; +} + +void dsp_processor_set_volume_curve_db_range(float db_range) { + if (db_range < 0.0f || db_range > 90.0f) { + ESP_LOGW(TAG, "%s: db_range %f out of range (0-90), clamping", __func__, db_range); + db_range = fminf(fmaxf(db_range, 0.0f), 90.0f); + } + if (db_range != volume_curve_db_range) { + float old_db_range = volume_curve_db_range; + volume_curve_db_range = db_range; + compute_curve(); + double new_curve = apply_volume_curve(last_slider_position); + ESP_LOGI(TAG, "Volume curve updated: %.0f->%.0f dB, dynamic_vol: %f->%f", + old_db_range, volume_curve_db_range, dynamic_vol, new_curve); + dynamic_vol = new_curve; + } +} + /** * Set parameters for a specific flow (without switching to it) */ diff --git a/components/dsp_processor/include/dsp_processor.h b/components/dsp_processor/include/dsp_processor.h index 5f9c3b3c..b415dfda 100644 --- a/components/dsp_processor/include/dsp_processor.h +++ b/components/dsp_processor/include/dsp_processor.h @@ -30,6 +30,12 @@ esp_err_t dsp_processor_update_filter_params(filterParams_t *params); void dsp_processor_set_volome(double volume); +// Set the volume curve dB range (0 = linear, 60 = standard, 90 = max). +// Maps slider [0,1] to amplitude [10^(-dB/20), 1.0]. +// Takes effect immediately; caller is responsible for persisting to NVS. +float dsp_processor_get_volume_curve_db_range(void); +void dsp_processor_set_volume_curve_db_range(float db_range); + /** * Set parameters for a specific flow (without switching to it) * This allows updating parameters for a flow that's not currently active diff --git a/components/dsp_processor_settings/dsp_processor_settings.c b/components/dsp_processor_settings/dsp_processor_settings.c index f83738bb..2634fb57 100644 --- a/components/dsp_processor_settings/dsp_processor_settings.c +++ b/components/dsp_processor_settings/dsp_processor_settings.c @@ -21,7 +21,6 @@ static const char *NVS_KEY_ACTIVE_FLOW = "active_flow"; // Mutex for thread-safe NVS access static SemaphoreHandle_t dsp_settings_mutex = NULL; - /** * Generate flow-specific NVS key * Format: "flow__" (e.g., "flow_5_fc_1") @@ -45,6 +44,22 @@ esp_err_t dsp_settings_init(void) { } ESP_LOGI(TAG, "%s: DSP settings manager initialized", __func__); + +#ifdef CONFIG_SNAPCLIENT_USE_SOFT_VOL + // Load volume curve from NVS now that the mutex exists, before + // restoring DSP params. This must happen here (not in dsp_processor_init) + // because the NVS helpers require dsp_settings_mutex. + float saved_db_range; + if (dsp_settings_load_volume_curve_db_range(&saved_db_range) == ESP_OK) { + dsp_processor_set_volume_curve_db_range(saved_db_range); + ESP_LOGI(TAG, "Restored volume curve from NVS: %.0f dB", + saved_db_range); + } else { + ESP_LOGI(TAG, "No saved volume curve in NVS, using default (%.0f dB)", + (float)CONFIG_SNAPCLIENT_VOLUME_CURVE_DB_RANGE); + } +#endif + // Restore DSP parameters into dsp_processor so the processor has the // persisted configuration after both modules are initialized. // Note: caller (main) must initialize dsp_processor before calling @@ -57,24 +72,31 @@ esp_err_t dsp_settings_init(void) { for (int f = 0; f < DSP_FLOW_COUNT; f++) { filterParams_t params; memset(¶ms, 0, sizeof(params)); - if (dsp_settings_get_flow_params((dspFlows_t)f, ¶ms) == ESP_OK) { - esp_err_t e = dsp_processor_set_params_for_flow((dspFlows_t)f, ¶ms); + if (dsp_settings_get_flow_params((dspFlows_t)f, ¶ms) == + ESP_OK) { + esp_err_t e = + dsp_processor_set_params_for_flow((dspFlows_t)f, ¶ms); if (e != ESP_OK) { - ESP_LOGW(TAG, "%s: Failed to apply params for flow %d: %s", __func__, f, esp_err_to_name(e)); + ESP_LOGW(TAG, "%s: Failed to apply params for flow %d: %s", + __func__, f, esp_err_to_name(e)); } else { - ESP_LOGD(TAG, "%s: Restored params for flow %d: fc_1=%.2f gain_1=%.2f fc_3=%.2f gain_3=%.2f", - __func__, f, params.fc_1, params.gain_1, params.fc_3, params.gain_3); + ESP_LOGD(TAG, + "%s: Restored params for flow %d: fc_1=%.2f " + "gain_1=%.2f fc_3=%.2f gain_3=%.2f", + __func__, f, params.fc_1, params.gain_1, + params.fc_3, params.gain_3); } } else { ESP_LOGD(TAG, "%s: No stored params for flow %d", __func__, f); } } } - + // Finally, instruct dsp_processor to switch to the active flow esp_err_t se = dsp_processor_switch_flow(active); if (se != ESP_OK) { - ESP_LOGW(TAG, "%s: dsp_processor_switch_flow failed: %s", __func__, esp_err_to_name(se)); + ESP_LOGW(TAG, "%s: dsp_processor_switch_flow failed: %s", __func__, + esp_err_to_name(se)); } return ESP_OK; } @@ -258,6 +280,16 @@ esp_err_t dsp_settings_get_json(char *json_out, size_t max_len) { (void)dsp_settings_load_active_flow(&active_flow); cJSON_AddNumberToObject(root, "active_flow", (int)active_flow); +#ifdef CONFIG_SNAPCLIENT_USE_SOFT_VOL + // Always emit the current in-memory value; NVS value takes precedence if present + float vol_curve = dsp_processor_get_volume_curve_db_range(); + float saved_vol_curve = -1.0f; + if (dsp_settings_load_volume_curve_db_range(&saved_vol_curve) == ESP_OK) { + vol_curve = saved_vol_curve; + } + cJSON_AddNumberToObject(root, "volume_curve_db_range", vol_curve); +#endif + // Add flow schema with current values cJSON *schema = cJSON_CreateArray(); if (!schema) { @@ -499,6 +531,22 @@ esp_err_t dsp_settings_set_from_json(const char *json_in) { } } +#ifdef CONFIG_SNAPCLIENT_USE_SOFT_VOL + // Handle volume curve dB range + cJSON *vol_curve = cJSON_GetObjectItem(root, "volume_curve_db_range"); + if (cJSON_IsNumber(vol_curve)) { + float vol_curve_val = (float)vol_curve->valueint; + dsp_processor_set_volume_curve_db_range(vol_curve_val); + esp_err_t save_err = + dsp_settings_save_volume_curve_db_range(vol_curve_val); + if (save_err != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to save volume_curve_db_range", __func__); + if (err == ESP_OK) + err = save_err; + } + } +#endif + // Iterate through all items and save flow parameters // Expecting keys like "flow_5_fc_1", "flow_5_gain_1", etc. cJSON *item = NULL; @@ -585,7 +633,9 @@ esp_err_t dsp_settings_set_flow_params(dspFlows_t flow, return ESP_ERR_INVALID_ARG; } - ESP_LOGI(TAG, "Setting params for flow %d: fc_1=%.1f gain_1=%.1f fc_3=%.1f gain_3=%.1f", + ESP_LOGI(TAG, + "Setting params for flow %d: fc_1=%.1f gain_1=%.1f fc_3=%.1f " + "gain_3=%.1f", flow, params->fc_1, params->gain_1, params->fc_3, params->gain_3); // Save to NVS @@ -600,10 +650,12 @@ esp_err_t dsp_settings_set_flow_params(dspFlows_t flow, // If the flow we just saved is currently active, apply it to the // DSP processor. Read active flow from NVS on demand. dspFlows_t active = dspfStereo; - if (dsp_settings_load_active_flow(&active) == ESP_OK && active == flow) { + if (dsp_settings_load_active_flow(&active) == ESP_OK && + active == flow) { esp_err_t e = dsp_processor_set_params_for_flow(flow, params); if (e == ESP_OK) { - ESP_LOGD(TAG, "Applied params to DSP processor for active flow"); + ESP_LOGD(TAG, + "Applied params to DSP processor for active flow"); } else { ESP_LOGW(TAG, "Failed to apply params to DSP processor: %s", esp_err_to_name(e)); @@ -632,7 +684,8 @@ esp_err_t dsp_settings_switch_active_flow(dspFlows_t flow) { if (dsp_settings_get_flow_params(flow, ¶ms) == ESP_OK) { esp_err_t e = dsp_processor_set_params_for_flow(flow, ¶ms); if (e != ESP_OK) { - ESP_LOGW(TAG, "Failed to set params for flow on DSP processor: %s", + ESP_LOGW(TAG, + "Failed to set params for flow on DSP processor: %s", esp_err_to_name(e)); } } else { @@ -651,3 +704,76 @@ esp_err_t dsp_settings_switch_active_flow(dspFlows_t flow) { return err; } + +static const char *NVS_KEY_VOLUME_CURVE_DB_RANGE = "vol_crv_db_rng"; + +esp_err_t dsp_settings_save_volume_curve_db_range(float db_range) { + ESP_LOGD(TAG, "%s: db_range=%f", __func__, db_range); + + if (!dsp_settings_mutex) + return ESP_ERR_INVALID_STATE; + + if (db_range < 0.0f || db_range > 90.0f) { + ESP_LOGW(TAG, "%s: db_range %f out of range (0-90)", __func__, + db_range); + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(dsp_settings_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) + return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(dsp_settings_mutex); + ESP_LOGE(TAG, "%s: Failed to open NVS: %s", __func__, + esp_err_to_name(err)); + return err; + } + + // Store as int32 (multiply by 10 for 1 decimal place precision) + int32_t val = (int32_t)(db_range * 10.0f); + err = nvs_set_i32(h, NVS_KEY_VOLUME_CURVE_DB_RANGE, val); + if (err == ESP_OK) + err = nvs_commit(h); + + nvs_close(h); + xSemaphoreGive(dsp_settings_mutex); + + if (err == ESP_OK) + ESP_LOGI(TAG, "%s: Saved volume curve dB range: %f", __func__, + db_range); + else + ESP_LOGE(TAG, "%s: Failed to save: %s", __func__, esp_err_to_name(err)); + + return err; +} + +esp_err_t dsp_settings_load_volume_curve_db_range(float *db_range) { + ESP_LOGD(TAG, "%s: entered", __func__); + + if (!db_range || !dsp_settings_mutex) + return ESP_ERR_INVALID_ARG; + + if (xSemaphoreTake(dsp_settings_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) + return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + int32_t val = 0; + err = nvs_get_i32(h, NVS_KEY_VOLUME_CURVE_DB_RANGE, &val); + nvs_close(h); + if (err == ESP_OK) { + *db_range = (float)val / 10.0f; + ESP_LOGI(TAG, "%s: Loaded volume curve dB range: %f", __func__, + *db_range); + } else if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGW(TAG, "%s: NVS read error: %s", __func__, + esp_err_to_name(err)); + } + } + + xSemaphoreGive(dsp_settings_mutex); + return err; +} diff --git a/components/dsp_processor_settings/include/dsp_processor_settings.h b/components/dsp_processor_settings/include/dsp_processor_settings.h index b25c56c9..5f93c129 100644 --- a/components/dsp_processor_settings/include/dsp_processor_settings.h +++ b/components/dsp_processor_settings/include/dsp_processor_settings.h @@ -129,6 +129,20 @@ esp_err_t dsp_settings_set_flow_params(dspFlows_t flow, */ esp_err_t dsp_settings_switch_active_flow(dspFlows_t flow); +/** + * Save volume curve dB range to NVS + * @param db_range dB range value (0-90) + * @return ESP_OK on success + */ +esp_err_t dsp_settings_save_volume_curve_db_range(float db_range); + +/** + * Load volume curve dB range from NVS + * @param db_range Pointer to store the loaded value + * @return ESP_OK on success, ESP_ERR_NVS_NOT_FOUND if not set + */ +esp_err_t dsp_settings_load_volume_curve_db_range(float *db_range); + #ifdef __cplusplus } #endif diff --git a/components/ui_http_server/html/dsp-settings.html b/components/ui_http_server/html/dsp-settings.html index 898f108b..8521cc60 100644 --- a/components/ui_http_server/html/dsp-settings.html +++ b/components/ui_http_server/html/dsp-settings.html @@ -140,6 +140,13 @@ color: #666; font-style: italic; } + + .global-settings { + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 20px; + } @@ -179,9 +186,24 @@

DSP Settings

// Render the complete UI based on capabilities async function renderUI() { const app = document.getElementById('app'); - + + // Global settings (not flow-specific) + let html = '
'; + const dbRange = capabilities.volume_curve_db_range !== undefined + ? capabilities.volume_curve_db_range : 60; + html += '
'; + html += '
'; + html += 'Volume Curve'; + html += `${dbRange.toFixed(0)} dB`; + html += '
'; + html += ``; + html += '
0 dB (linear)90 dB
'; + html += '
'; + html += '
'; + // Create flow selector - let html = '
'; + html += '
'; html += ''; html += '