From 0b12323921a045f85e68a15d88fd552b240993b0 Mon Sep 17 00:00:00 2001 From: pober Date: Thu, 9 Apr 2026 09:54:14 +0000 Subject: [PATCH] fix/gain-4dbU (#25) Co-authored-by: Pbopbo Reviewed-on: https://gitea.summitwave.work/auracaster/bumble-auracast/pulls/25 --- src/auracast/auracast_config.py | 4 +- src/auracast/multicast.py | 15 ---- src/auracast/server/multicast_frontend.py | 95 +++++++++++++++-------- src/auracast/server/multicast_server.py | 93 +++++++++++++++++----- 4 files changed, 139 insertions(+), 68 deletions(-) diff --git a/src/auracast/auracast_config.py b/src/auracast/auracast_config.py index 0b0daf8..c363114 100644 --- a/src/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -55,7 +55,6 @@ class AuracastBigConfig(BaseModel): precode_wav: bool = False iso_que_len: int = 64 num_bis: int = 1 # 1 = mono (FRONT_LEFT), 2 = stereo (FRONT_LEFT + FRONT_RIGHT) - input_gain_db: float = 0.0 # Software gain boost in dB applied before LC3 encoding (0 = off, max 20) class AuracastBigConfigDeu(AuracastBigConfig): id: int = 12 @@ -112,4 +111,5 @@ class AuracastConfigGroup(AuracastGlobalConfig): bigs: List[AuracastBigConfig] = [ AuracastBigConfigDeu(), ] - analog_gain: int = 50 # ADC gain level for analog mode (10-60%) + analog_gain_db_left: float = 0.0 # ADC gain level for analog mode left channel (-12 to 18 dB) + analog_gain_db_right: float = 0.0 # ADC gain level for analog mode right channel (-12 to 18 dB) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 75483d3..c4ae081 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -862,13 +862,6 @@ class Streamer(): big['encoder'] = encoders[0] big['precoded'] = False - # Pre-compute software gain multiplier from dB config (0 dB = 1.0 = no change) - gain_db = getattr(big_config[i], 'input_gain_db', 0.0) - gain_db = max(0.0, min(20.0, float(gain_db))) - big['_gain_linear'] = 10.0 ** (gain_db / 20.0) if gain_db > 0 else 0.0 - if big['_gain_linear'] > 0.0: - logging.info("Software gain for BIG %d: +%.1f dB (linear %.3f)", i, gain_db, big['_gain_linear']) - logging.info("Streaming audio...") bigs = self.bigs self.is_streaming = True @@ -916,14 +909,6 @@ class Streamer(): stream_finished[i] = True continue - # Apply software gain boost if configured (> 0 dB) - gain_lin = big.get('_gain_linear', 0.0) - if gain_lin > 0.0: - pcm_arr = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32) - pcm_arr *= gain_lin - np.clip(pcm_arr, -32768, 32767, out=pcm_arr) - pcm_frame = pcm_arr.astype(np.int16).tobytes() - # Compute RMS audio level (normalized 0.0-1.0) for level monitoring pcm_samples = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32) rms = np.sqrt(np.mean(pcm_samples ** 2)) / 32768.0 if len(pcm_samples) > 0 else 0.0 diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index e5c4ed0..da2de01 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -198,35 +198,64 @@ else: start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming) # Analog gain control (only for Analog mode, placed below start button) -analog_gain_value = 50 # default (ALSA 10-60 range) -software_boost_db = 0 # default +analog_gain_db_left = 0 # default (dB) +analog_gain_db_right = 0 # default (dB) if audio_mode == "Analog": - saved_analog_gain = saved_settings.get('analog_gain', 50) - # Convert persisted ALSA value (10-60) to display value (0-100) - saved_display = int(round((saved_analog_gain - 10) * 100 / 50)) - saved_display = max(0, min(100, saved_display)) - analog_gain_display = st.slider( - "Analog Input Gain", - min_value=0, - max_value=100, - value=saved_display, - step=5, - disabled=is_streaming, - format="%d%%", - help="ADC gain level for both analog inputs. Default is 80%." - ) - # Map display value (0-100) back to ALSA range (10-60) - analog_gain_value = int(round(10 + analog_gain_display * 50 / 100)) - saved_boost = saved_settings.get('software_boost_db', 0) - software_boost_db = st.slider( - "Boost", - min_value=0, - max_value=20, - value=min(int(saved_boost), 20), - step=1, - disabled=is_streaming, - help="Digital gain boost applied before encoding (0-20 dB). Use this when the line-level signal is too quiet even at max ADC gain. Higher values may cause clipping on loud signals." + if '_analog_gain_db_left' not in st.session_state: + st.session_state['_analog_gain_db_left'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_left', 0)))) + if '_analog_gain_db_right' not in st.session_state: + st.session_state['_analog_gain_db_right'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_right', 0)))) + if '_gain_link_channels' not in st.session_state: + st.session_state['_gain_link_channels'] = True + + link_channels = st.checkbox( + "Link audio channel gain", + key='_gain_link_channels', + help="When enabled, Ch 2 mirrors Ch 1." ) + _gain_col1, _gain_col2 = st.columns(2) + with _gain_col1: + analog_gain_db_left = st.slider( + "Ch 1 Input Gain", + min_value=-12, + max_value=18, + key='_analog_gain_db_left', + step=1, + format="%d dB", + help="ADC gain for channel 1 (-12 to 18 dB). Default is 0 dB." + ) + with _gain_col2: + if link_channels: + st.session_state['_analog_gain_db_right'] = analog_gain_db_left + elif st.session_state.get('_prev_gain_link_channels', True): + # Transition: just unlinked — seed Ch 2 from Ch 1 so it doesn't jump to min + st.session_state['_analog_gain_db_right'] = analog_gain_db_left + st.session_state['_prev_gain_link_channels'] = link_channels + analog_gain_db_right = st.slider( + "Ch 2 Input Gain", + min_value=-12, + max_value=18, + key='_analog_gain_db_right', + step=1, + format="%d dB", + disabled=link_channels, + help="Uncheck 'Link audio channel gain' to adjust Ch 2 independently." if link_channels else "ADC gain for channel 2 (-12 to 18 dB). Default is 0 dB." + ) + # Apply gain live while streaming whenever either slider value changes + if is_streaming: + prev_left = st.session_state.get('_prev_analog_gain_db_left') + prev_right = st.session_state.get('_prev_analog_gain_db_right') + if prev_left != analog_gain_db_left or prev_right != analog_gain_db_right: + try: + requests.post( + f"{BACKEND_URL}/adc_gain", + json={"gain_db_left": analog_gain_db_left, "gain_db_right": analog_gain_db_right}, + timeout=1, + ) + except Exception: + pass + st.session_state['_prev_analog_gain_db_left'] = analog_gain_db_left + st.session_state['_prev_analog_gain_db_right'] = analog_gain_db_right # Audio level monitor (checkbox, not persisted across reloads) show_level_monitor = st.checkbox("Audio level monitor", value=False, disabled=not is_streaming, @@ -684,8 +713,8 @@ else: 'immediate_rendering': immediate_rendering2, 'presentation_delay_ms': presentation_delay_ms2, 'qos_preset': qos_preset2, - 'analog_gain': analog_gain_value, - 'software_boost_db': software_boost_db, + 'analog_gain_db_left': analog_gain_db_left, + 'analog_gain_db_right': analog_gain_db_right, } radio1_cfg = { @@ -701,8 +730,8 @@ else: 'presentation_delay_ms': presentation_delay_ms1, 'qos_preset': qos_preset1, 'stereo_mode': stereo_enabled, - 'analog_gain': analog_gain_value, - 'software_boost_db': software_boost_db, + 'analog_gain_db_left': analog_gain_db_left, + 'analog_gain_db_right': analog_gain_db_right, } if audio_mode == "Network - Dante": @@ -1585,7 +1614,8 @@ if start_stream: immediate_rendering=bool(cfg['immediate_rendering']), presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000), qos_config=QOS_PRESET_MAP[cfg['qos_preset']], - analog_gain=cfg.get('analog_gain', 50), + analog_gain_db_left=cfg.get('analog_gain_db_left', 0.0), + analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0), bigs=[ auracast_config.AuracastBigConfig( code=(cfg['stream_passwort'].strip() or None), @@ -1598,7 +1628,6 @@ if start_stream: sampling_frequency=q['rate'], octets_per_frame=q['octets'], num_bis=channels, # 1=mono, 2=stereo - this determines the behavior - input_gain_db=float(cfg.get('software_boost_db', 0)), ) ], ) diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index c6d45f2..1c9f615 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -3,6 +3,7 @@ TODO: in the future the multicaster objects should run in their own threads or e """ import os +import re import logging as log import json from datetime import datetime @@ -293,16 +294,18 @@ async def _init_i2c_on_startup() -> None: log.warning("Exception running i2cget (%s): %s", " ".join(read_cmd), e, exc_info=True) -async def _set_adc_level(gain_percent: int = 50) -> None: - """Set ADC mixer level. +async def _set_adc_level(gain_db_left: float = 0.0, gain_db_right: float = 0.0) -> None: + """Set ADC mixer gain in dB for left and right channels independently. - Runs: amixer -c 1 set 'ADC' x% + Runs: amixer -c 2 sset ADC {gain_db_left}dB,{gain_db_right}dB Args: - gain_percent: Gain level 10-60, default 50 (above 60% causes clipping/silence) + gain_db_left: Left channel gain in dB (-12 to 18), default 0 + gain_db_right: Right channel gain in dB (-12 to 18), default 0 """ - gain_percent = max(10, min(60, gain_percent)) - cmd = ["amixer", "-c", "1", "set", "ADC", f"{gain_percent}%"] + gain_db_left = max(-12.0, min(18.0, gain_db_left)) + gain_db_right = max(-12.0, min(18.0, gain_db_right)) + cmd = ["amixer", "-c", "2", "sset", "ADC", "--", f"{int(gain_db_left)}dB,{int(gain_db_right)}dB"] try: proc = await asyncio.create_subprocess_exec( *cmd, @@ -311,15 +314,46 @@ async def _set_adc_level(gain_percent: int = 50) -> None: ) stdout, stderr = await proc.communicate() if proc.returncode != 0: - log.warning( + log.error( "amixer ADC level command failed (rc=%s): %s", proc.returncode, (stderr or b"" ).decode(errors="ignore").strip(), ) else: log.info("amixer ADC level set successfully: %s", (stdout or b"" ).decode(errors="ignore").strip()) + read_proc = await asyncio.create_subprocess_exec( + "amixer", "-c", "2", "sget", "ADC", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + read_stdout, read_stderr = await read_proc.communicate() + if read_proc.returncode != 0: + log.error( + "amixer ADC sget failed (rc=%s): %s", + read_proc.returncode, + (read_stderr or b"").decode(errors="ignore").strip(), + ) + else: + sget_output = (read_stdout or b"").decode(errors="ignore") + actual = {} + for line in sget_output.splitlines(): + for ch_key, ch_name in (("left", "Front Left"), ("right", "Front Right")): + if ch_name in line: + m = re.search(r'\[(-?\d+(?:\.\d+)?)dB\]', line) + if m: + actual[ch_key] = round(float(m.group(1))) + expected_left = int(gain_db_left) + expected_right = int(gain_db_right) + if actual.get("left") != expected_left or actual.get("right") != expected_right: + mismatch = ( + f"ADC level mismatch after set: expected L={expected_left}dB R={expected_right}dB, " + f"got L={actual.get('left')}dB R={actual.get('right')}dB" + ) + log.error(mismatch) + else: + log.info("ADC level set successfully: L=%sdB R=%sdB", expected_left, expected_right) except Exception as e: - log.warning("Exception running amixer ADC level command: %s", e, exc_info=True) + log.error("Exception running amixer ADC level command: %s", e, exc_info=True) async def _stop_all() -> bool: @@ -399,8 +433,9 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, if input_device_name in ('ch1', 'ch2'): audio_mode_persist = 'Analog' # Set ADC gain level for analog mode - analog_gain = getattr(conf, 'analog_gain', 50) - await _set_adc_level(analog_gain) + analog_gain_db_left = getattr(conf, 'analog_gain_db_left', 0.0) + analog_gain_db_right = getattr(conf, 'analog_gain_db_right', 0.0) + await _set_adc_level(analog_gain_db_left, analog_gain_db_right) elif input_device_name in dante_channels: audio_mode_persist = 'Network - Dante' else: @@ -548,8 +583,8 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, 'immediate_rendering': getattr(conf, 'immediate_rendering', False), 'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False), 'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False, - 'analog_gain': getattr(conf, 'analog_gain', 50), - 'software_boost_db': getattr(conf.bigs[0], 'input_gain_db', 0.0) if conf.bigs else 0.0, + 'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0), + 'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0), 'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None), 'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs], 'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs], @@ -622,6 +657,28 @@ async def stop_audio(): log.error("Exception in /stop_audio: %s", traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) +@app.post("/adc_gain") +async def set_adc_gain(payload: dict): + """Set ADC gain in dB for left and right channels without restarting the stream. + + Body: {"gain_db_left": float, "gain_db_right": float} + """ + try: + gain_db_left = float(payload.get("gain_db_left", 0.0)) + gain_db_right = float(payload.get("gain_db_right", 0.0)) + await _set_adc_level(gain_db_left, gain_db_right) + # Persist the new values so they survive a restart + for load_fn, save_fn in [(load_stream_settings, save_stream_settings), (load_stream_settings2, save_stream_settings2)]: + s = load_fn() or {} + if s: + s['analog_gain_db_left'] = gain_db_left + s['analog_gain_db_right'] = gain_db_right + save_fn(s) + return {"status": "ok", "gain_db_left": gain_db_left, "gain_db_right": gain_db_right} + except Exception as e: + log.error("Exception in /adc_gain: %s", traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + @app.post("/stream_lc3") async def send_audio(audio_data: dict[str, str]): """Sends a block of pre-coded LC3 audio via the worker.""" @@ -818,7 +875,6 @@ async def _autostart_from_settings(): iso_que_len=1, sampling_frequency=rate, octets_per_frame=octets, - input_gain_db=float(settings.get('software_boost_db', 0)), ) ] conf = auracast_config.AuracastConfigGroup( @@ -828,7 +884,8 @@ async def _autostart_from_settings(): immediate_rendering=immediate_rendering, assisted_listening_stream=assisted_listening_stream, presentation_delay_us=pres_delay if pres_delay is not None else 40000, - analog_gain=settings.get('analog_gain', 50), + analog_gain_db_left=settings.get('analog_gain_db_left', 0.0), + analog_gain_db_right=settings.get('analog_gain_db_right', 0.0), bigs=bigs, ) # Set num_bis for stereo mode if needed @@ -973,7 +1030,6 @@ async def _autostart_from_settings(): iso_que_len=1, sampling_frequency=rate, octets_per_frame=octets, - input_gain_db=float(settings.get('software_boost_db', 0)), ) ] conf = auracast_config.AuracastConfigGroup( @@ -983,7 +1039,8 @@ async def _autostart_from_settings(): immediate_rendering=immediate_rendering, assisted_listening_stream=assisted_listening_stream, presentation_delay_us=pres_delay if pres_delay is not None else 40000, - analog_gain=settings.get('analog_gain', 50), + analog_gain_db_left=settings.get('analog_gain_db_left', 0.0), + analog_gain_db_right=settings.get('analog_gain_db_right', 0.0), bigs=bigs, ) conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"]) @@ -1022,8 +1079,8 @@ async def _startup_autostart_event(): _load_led_settings() _init_settings_cache_from_disk() await _init_i2c_on_startup() - # Ensure ADC mixer level is set at startup (default 50%) - await _set_adc_level(50) + # Ensure ADC mixer level is set at startup (default 0 dB) + await _set_adc_level(0.0, 0.0) refresh_pw_cache() log.info("[STARTUP] Scheduling autostart task") asyncio.create_task(_autostart_from_settings())