diff --git a/src/rigplane/audio/bridge.py b/src/rigplane/audio/bridge.py index 202854cda..8eca41b69 100644 --- a/src/rigplane/audio/bridge.py +++ b/src/rigplane/audio/bridge.py @@ -152,6 +152,24 @@ def _pcm16le_samples(pcm: bytes) -> array[int]: return samples +def _apply_pcm_gain_db(pcm: bytes, gain_db: float) -> bytes: + """Apply gain to s16le PCM, saturating samples at the int16 limits.""" + if gain_db == 0.0: + return pcm + if not math.isfinite(gain_db): + raise ValueError("Audio gain must be a finite dB value") + + # A factor of 32768 already clips every nonzero int16 sample. + scale = 10.0 ** (min(gain_db, 20.0 * math.log10(32768)) / 20.0) + samples = _pcm16le_samples(pcm) + for index, sample in enumerate(samples): + scaled = round(sample * scale) + samples[index] = max(-32768, min(32767, scaled)) + if sys.byteorder != "little": + samples.byteswap() + return samples.tobytes() + + def _downmix_stereo_to_mono(pcm: bytes) -> bytes: """Downmix L+R interleaved s16le → mono s16le via average. @@ -288,6 +306,8 @@ class AudioBridge: sample_rate: PCM sample rate (default 48000). channels: Number of audio channels (default 1, mono). frame_ms: PCM frame duration in milliseconds (default 20). + input_gain_db: Gain applied to device-to-radio PCM (default 0 dB). + output_gain_db: Gain applied to radio-to-device PCM (default 0 dB). tx_enabled: Whether to bridge TX audio (device → radio). Default True. tx_executor: Accepted but ignored (deprecated) — the backend owns threading. Kept only so existing callers passing it keep working. @@ -317,6 +337,8 @@ def __init__( sample_rate: int = SAMPLE_RATE, channels: int = CHANNELS, frame_ms: int = FRAME_MS, + input_gain_db: float = 0.0, + output_gain_db: float = 0.0, tx_enabled: bool = True, tx_executor: Executor | None = None, label: str = "rigplane", @@ -336,6 +358,12 @@ def __init__( self._sample_rate = sample_rate self._channels = channels self._frame_ms = frame_ms + self._input_gain_db = 0.0 + self._output_gain_db = 0.0 + self.set_gains( + input_gain_db=input_gain_db, + output_gain_db=output_gain_db, + ) self._tx_enabled = tx_enabled self._tx_started = False self._backend: AudioBackend = backend or PortAudioBackend() @@ -422,6 +450,29 @@ def label(self) -> str: def running(self) -> bool: return self._running + @property + def input_gain_db(self) -> float: + return self._input_gain_db + + @property + def output_gain_db(self) -> float: + return self._output_gain_db + + def set_gains( + self, + *, + input_gain_db: float | None = None, + output_gain_db: float | None = None, + ) -> None: + """Update local bridge gains without changing shared Web UI audio.""" + for value in (input_gain_db, output_gain_db): + if value is not None and not math.isfinite(value): + raise ValueError("Audio gain must be a finite dB value") + if input_gain_db is not None: + self._input_gain_db = input_gain_db + if output_gain_db is not None: + self._output_gain_db = output_gain_db + @property def bridge_state(self) -> BridgeState: return self._bridge_state @@ -1189,15 +1240,16 @@ async def _rx_loop(self) -> None: if len(self._rx_latency_samples) > 100: self._rx_latency_samples.pop(0) self._last_rx_time = now + out_data = pcm_data + if self._input_channels == 2: + out_data = _downmix_stereo_to_mono(out_data) + out_data = _apply_pcm_gain_db(out_data, self._output_gain_db) self._rx_frames += 1 - self._last_rx_level_dbfs = _rms_dbfs(pcm_data) + self._last_rx_level_dbfs = _rms_dbfs(out_data) if self._rx_frames % 50 == 0: self._emit_metrics() playback = self._playback_stream if playback and playback.running: - out_data = pcm_data - if self._input_channels == 2: - out_data = _downmix_stereo_to_mono(out_data) await playback.write(out_data) except OSError: raise # device-level error → outer handler → reconnect @@ -1245,6 +1297,7 @@ async def _tx_loop(self) -> None: self._drop_queued_tx() continue + pcm_bytes = _apply_pcm_gain_db(pcm_bytes, self._input_gain_db) samples = _pcm16le_samples(pcm_bytes) peak = max((abs(sample) for sample in samples), default=0) if peak < silence_threshold: diff --git a/src/rigplane/cli/__init__.py b/src/rigplane/cli/__init__.py index 2bbb9fc11..1573fef8e 100644 --- a/src/rigplane/cli/__init__.py +++ b/src/rigplane/cli/__init__.py @@ -579,6 +579,16 @@ def _apply_managed_runtime_defaults(args: argparse.Namespace) -> None: args.web_rigctld = True +def _bridge_gain_db(raw: str) -> float: + try: + gain = float(raw) + except ValueError: + raise argparse.ArgumentTypeError(f"{raw!r} is not a dB value") from None + if not isfinite(gain): + raise argparse.ArgumentTypeError(f"{raw!r} must be a finite dB value") + return gain + + def _ptt_hold_seconds(raw: str) -> float: """Parse ``--for``: a finite, positive number of seconds and nothing else. @@ -1031,6 +1041,20 @@ def _add_audio_common_flags(sp: argparse.ArgumentParser) -> None: action="store_true", help="RX only (don't bridge TX from device to radio)", ) + audio_bridge_p.add_argument( + "--input-gain-db", + type=_bridge_gain_db, + default=0.0, + metavar="DB", + help="Gain for device-to-radio audio in dB (default: 0.0)", + ) + audio_bridge_p.add_argument( + "--output-gain-db", + type=_bridge_gain_db, + default=0.0, + metavar="DB", + help="Gain for radio-to-device audio in dB (default: 0.0)", + ) audio_bridge_p.add_argument( "--list-devices", action="store_true", @@ -1436,6 +1460,22 @@ def _add_audio_common_flags(sp: argparse.ArgumentParser) -> None: action="store_true", help="Bridge RX only (no TX from virtual device to radio)", ) + web_p.add_argument( + "--bridge-input-gain-db", + dest="web_bridge_input_gain_db", + type=_bridge_gain_db, + default=0.0, + metavar="DB", + help="Bridge gain for device-to-radio audio in dB (default: 0.0)", + ) + web_p.add_argument( + "--bridge-output-gain-db", + dest="web_bridge_output_gain_db", + type=_bridge_gain_db, + default=0.0, + metavar="DB", + help="Bridge gain for radio-to-device audio in dB (default: 0.0)", + ) web_p.add_argument( "--bridge-label", dest="web_bridge_label", @@ -3996,6 +4036,8 @@ async def _cmd_audio_bridge(radio: Radio, args: argparse.Namespace) -> int: radio, device_name=args.device, tx_device_name=getattr(args, "tx_device", None), + input_gain_db=getattr(args, "input_gain_db", 0.0), + output_gain_db=getattr(args, "output_gain_db", 0.0), tx_enabled=not args.rx_only, label=bridge_label, max_retries=getattr(args, "max_retries", 5), @@ -4341,6 +4383,8 @@ async def _cmd_web( device_name=None, tx_device_name=getattr(args, "web_bridge_tx_device", None), tx_enabled=not getattr(args, "web_bridge_rx_only", False), + input_gain_db=getattr(args, "web_bridge_input_gain_db", 0.0), + output_gain_db=getattr(args, "web_bridge_output_gain_db", 0.0), label=getattr(args, "web_bridge_label", None), max_retries=getattr(args, "web_bridge_max_retries", 5), retry_base_delay=getattr(args, "web_bridge_retry_delay", 1.0), @@ -4359,6 +4403,8 @@ async def _cmd_web( device_name=device_name, tx_device_name=tx_device_name, tx_enabled=not rx_only, + input_gain_db=getattr(args, "web_bridge_input_gain_db", 0.0), + output_gain_db=getattr(args, "web_bridge_output_gain_db", 0.0), label=bridge_label, max_retries=getattr(args, "web_bridge_max_retries", 5), retry_base_delay=getattr(args, "web_bridge_retry_delay", 1.0), diff --git a/src/rigplane/web/api_contract.py b/src/rigplane/web/api_contract.py index e5840ea3b..e5744ad8c 100644 --- a/src/rigplane/web/api_contract.py +++ b/src/rigplane/web/api_contract.py @@ -104,6 +104,12 @@ class ResponseFieldContract(TypedDict): "purpose": "start audio bridge", "auth": "none", }, + { + "method": "PATCH", + "path": "/api/v1/bridge", + "purpose": "adjust running audio bridge gains", + "auth": "none", + }, { "method": "DELETE", "path": "/api/v1/bridge", diff --git a/src/rigplane/web/server.py b/src/rigplane/web/server.py index 285f54690..b8004e54e 100644 --- a/src/rigplane/web/server.py +++ b/src/rigplane/web/server.py @@ -3388,6 +3388,9 @@ async def start_audio_bridge( label: str | None = None, max_retries: int = 5, retry_base_delay: float = 1.0, + *, + input_gain_db: float = 0.0, + output_gain_db: float = 0.0, ) -> None: """Start the audio bridge to a virtual audio device. @@ -3396,6 +3399,8 @@ async def start_audio_bridge( tx_device_name: Separate device for TX (e.g. "RigPlane Virtual Cable Input"). Required for bidirectional audio to avoid feedback. tx_enabled: Whether to bridge TX (device → radio). + input_gain_db: Gain applied to device-to-radio PCM. + output_gain_db: Gain applied to radio-to-device PCM. label: Descriptive label for log messages. If ``None``, derived from radio model. """ from rigplane.audio.bridge import AudioBridge, derive_bridge_label @@ -3416,6 +3421,8 @@ async def start_audio_bridge( self._radio, # type: ignore[arg-type] device_name=device_name, tx_device_name=tx_device_name, + input_gain_db=input_gain_db, + output_gain_db=output_gain_db, tx_enabled=tx_enabled, label=label, max_retries=max_retries, @@ -3450,6 +3457,9 @@ def defer_audio_bridge_start( label: str | None = None, max_retries: int = 5, retry_base_delay: float = 1.0, + *, + input_gain_db: float = 0.0, + output_gain_db: float = 0.0, ) -> None: """Arm ONE deferred auto-start of the audio bridge (MOR-3078). @@ -3466,6 +3476,8 @@ def defer_audio_bridge_start( "device_name": device_name, "tx_device_name": tx_device_name, "tx_enabled": tx_enabled, + "input_gain_db": input_gain_db, + "output_gain_db": output_gain_db, "label": label, "max_retries": max_retries, "retry_base_delay": retry_base_delay, @@ -3477,6 +3489,8 @@ async def _run_deferred_audio_bridge_start( device_name: str | None, tx_device_name: str | None, tx_enabled: bool, + input_gain_db: float, + output_gain_db: float, label: str | None, max_retries: int, retry_base_delay: float, @@ -3498,6 +3512,8 @@ async def _run_deferred_audio_bridge_start( device_name=device_name, tx_device_name=tx_device_name, tx_enabled=tx_enabled, + input_gain_db=input_gain_db, + output_gain_db=output_gain_db, label=label, max_retries=max_retries, retry_base_delay=retry_base_delay, @@ -6912,13 +6928,20 @@ async def _handle_bridge( self, method: str, writer: asyncio.StreamWriter, + headers: dict[str, str] | None = None, + reader: asyncio.StreamReader | None = None, ) -> None: - """Handle /api/v1/bridge — GET status, POST start, DELETE stop.""" + """Handle bridge status, lifecycle, and dynamic gain updates.""" if method == "GET": stats = self.audio_bridge_stats + bridge = self._audio_bridge body = json.dumps( { "running": stats is not None and stats.get("running", False), + "inputGainDb": bridge.input_gain_db if bridge is not None else 0.0, + "outputGainDb": ( + bridge.output_gain_db if bridge is not None else 0.0 + ), **(stats or {}), }, separators=(",", ":"), @@ -6953,6 +6976,65 @@ async def _handle_bridge( body, {"Content-Type": "application/json"}, ) + elif method == "PATCH": + bridge = self._audio_bridge + if bridge is None or not bridge.running: + await _send_diag_error( + writer, + 409, + "bridge_not_running", + "audio bridge must be running to adjust gain", + ) + return + payload = await self._read_json_body(writer, headers, reader) + if payload is None: + return + allowed = {"inputGainDb", "outputGainDb"} + if not payload or set(payload) - allowed: + await _send_diag_error( + writer, + 400, + "invalid_bridge_gain", + "inputGainDb or outputGainDb is required", + ) + return + + gains: dict[str, float] = {} + for key in allowed & set(payload): + value = payload[key] + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or abs(value) > sys.float_info.max + or not math.isfinite(value) + ): + await _send_diag_error( + writer, + 400, + "invalid_bridge_gain", + f"{key} must be a finite number", + ) + return + gains[key] = float(value) + + bridge.set_gains( + input_gain_db=gains.get("inputGainDb"), + output_gain_db=gains.get("outputGainDb"), + ) + body = json.dumps( + { + "inputGainDb": bridge.input_gain_db, + "outputGainDb": bridge.output_gain_db, + }, + separators=(",", ":"), + ).encode() + await _send_response( + writer, + 200, + "OK", + body, + {"Content-Type": "application/json"}, + ) elif method == "DELETE": await self.stop_audio_bridge() body = json.dumps({"status": "stopped"}, separators=(",", ":")).encode() diff --git a/src/rigplane/web/web_routing.py b/src/rigplane/web/web_routing.py index 544e20232..deb87db88 100644 --- a/src/rigplane/web/web_routing.py +++ b/src/rigplane/web/web_routing.py @@ -234,10 +234,10 @@ async def _dispatch_http_routes( return if path == "/api/v1/bridge": - if method not in ("GET", "HEAD", "POST", "DELETE"): + if method not in ("GET", "HEAD", "POST", "PATCH", "DELETE"): await _send_response(writer, 405, "Method Not Allowed", b"", {}) return - await server._handle_bridge(method, writer) + await server._handle_bridge(method, writer, headers, reader) return if path in ( "/api/v1/radio/disconnect", diff --git a/tests/test_audio_bridge.py b/tests/test_audio_bridge.py index 8931a916e..29cf58141 100644 --- a/tests/test_audio_bridge.py +++ b/tests/test_audio_bridge.py @@ -144,6 +144,66 @@ def test_constants(): assert FRAME_BYTES == 1920 +def test_pcm_gain_zero_db_preserves_original_bytes(): + from rigplane.audio.bridge import _apply_pcm_gain_db + + pcm = b"\x00\x80\xff\x7f\x34\x12" + + assert _apply_pcm_gain_db(pcm, 0.0) is pcm + + +def test_pcm_gain_attenuates_and_amplifies_with_saturation(): + from rigplane.audio.bridge import _apply_pcm_gain_db + + pcm = b"".join( + sample.to_bytes(2, "little", signed=True) + for sample in (-20_000, -1_000, 1_000, 20_000) + ) + + attenuated = _apply_pcm_gain_db(pcm, -6.020599913279624) + amplified = _apply_pcm_gain_db(pcm, 6.020599913279624) + + assert list(memoryview(attenuated).cast("h")) == [-10_000, -500, 500, 10_000] + assert list(memoryview(amplified).cast("h")) == [-32_768, -2_000, 2_000, 32_767] + + +def test_bridge_gain_can_be_updated_dynamically(): + bridge = AudioBridge(_bare_radio()) + + bridge.set_gains(input_gain_db=6.0) + bridge.set_gains(output_gain_db=-10.0) + + assert bridge.input_gain_db == 6.0 + assert bridge.output_gain_db == -10.0 + + +@pytest.mark.parametrize( + ("gain_db", "expected"), + [ + (1e308, [-32768, 0, 32767]), + (-1e308, [0, 0, 0]), + ], +) +def test_pcm_gain_extreme_finite_values(gain_db, expected): + from rigplane.audio.bridge import _apply_pcm_gain_db + + pcm = b"".join(sample.to_bytes(2, "little", signed=True) for sample in (-1, 0, 1)) + result = _apply_pcm_gain_db(pcm, gain_db) + + assert [ + int.from_bytes(result[index : index + 2], "little", signed=True) + for index in range(0, len(result), 2) + ] == expected + + +@pytest.mark.parametrize("gain_db", [float("nan"), float("inf"), float("-inf")]) +def test_bridge_gain_rejects_non_finite_values(gain_db: float): + bridge = AudioBridge(_bare_radio()) + + with pytest.raises(ValueError, match="finite"): + bridge.set_gains(input_gain_db=gain_db) + + # --------------------------------------------------------------------------- # find_loopback_device (legacy compat) # --------------------------------------------------------------------------- @@ -1473,6 +1533,75 @@ async def test_bridge_metrics_surface_capture_overflow_separately_from_queue_dro await bridge.stop() +async def test_bridge_applies_output_gain_to_radio_audio_playback(): + radio = _make_radio() + backend = _bridge_backend() + bridge = AudioBridge( + radio, + device_name="RigPlane Virtual Cable", + tx_enabled=False, + output_gain_db=-6.020599913279624, + backend=backend, + ) + await bridge.start() + try: + frame = (2_000).to_bytes(2, "little", signed=True) * SAMPLES_PER_FRAME + radio.audio_bus._on_opus_packet(AudioPacket(ident=0x80, send_seq=1, data=frame)) + + await _drain_bridge_callbacks() + + expected = (1_000).to_bytes(2, "little", signed=True) * SAMPLES_PER_FRAME + assert backend.tx_streams[0].written_frames == [expected] + finally: + await bridge.stop() + + +async def test_bridge_output_gain_does_not_mutate_shared_radio_audio(): + radio = _make_radio() + backend = _bridge_backend() + bridge = AudioBridge( + radio, + device_name="RigPlane Virtual Cable", + tx_enabled=False, + output_gain_db=-10.0, + backend=backend, + ) + await bridge.start() + try: + frame = (2_000).to_bytes(2, "little", signed=True) * SAMPLES_PER_FRAME + packet = AudioPacket(ident=0x80, send_seq=1, data=frame) + radio.audio_bus._on_opus_packet(packet) + + await _drain_bridge_callbacks() + + assert packet.data is frame + assert backend.tx_streams[0].written_frames[0] != frame + finally: + await bridge.stop() + + +async def test_bridge_applies_input_gain_to_captured_audio(): + radio = _make_radio() + backend = _bridge_backend() + bridge = AudioBridge( + radio, + device_name="RigPlane Virtual Cable", + input_gain_db=6.020599913279624, + backend=backend, + ) + await bridge.start() + try: + frame = (1_000).to_bytes(2, "little", signed=True) * SAMPLES_PER_FRAME + backend.rx_streams[0].inject_frame(frame) + + await _drain_bridge_callbacks() + + expected = (2_000).to_bytes(2, "little", signed=True) * SAMPLES_PER_FRAME + radio.push_audio_tx_pcm.assert_awaited_once_with(expected) + finally: + await bridge.stop() + + async def test_bridge_metrics_track_silence_suppression_separately_from_capture_health(): radio = _make_radio() backend = _bridge_backend() diff --git a/tests/test_cli.py b/tests/test_cli.py index a87a44e8e..b9297f351 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1311,6 +1311,65 @@ def test_unknown_preset_exits(self): _apply_preset(args, "nonexistent") +class TestBridgeGainArguments: + @pytest.mark.parametrize( + ("command", "option"), + [ + (["audio", "bridge"], "--input-gain-db"), + (["audio", "bridge"], "--output-gain-db"), + (["web"], "--bridge-input-gain-db"), + (["web"], "--bridge-output-gain-db"), + ], + ) + @pytest.mark.parametrize("value", ["nan", "inf", "-inf"]) + def test_bridge_gain_rejects_non_finite_arguments(self, command, option, value): + with pytest.raises(SystemExit) as exc: + _build_parser().parse_args([*command, f"{option}={value}"]) + + assert exc.value.code == 2 + + def test_audio_bridge_gain_defaults_are_zero_db(self): + args = _build_parser().parse_args(["audio", "bridge"]) + + assert args.input_gain_db == 0.0 + assert args.output_gain_db == 0.0 + + def test_audio_bridge_accepts_input_and_output_gain(self): + args = _build_parser().parse_args( + [ + "audio", + "bridge", + "--input-gain-db", + "6.0", + "--output-gain-db", + "-10.0", + ] + ) + + assert args.input_gain_db == 6.0 + assert args.output_gain_db == -10.0 + + def test_web_bridge_gain_defaults_are_zero_db(self): + args = _build_parser().parse_args(["web"]) + + assert args.web_bridge_input_gain_db == 0.0 + assert args.web_bridge_output_gain_db == 0.0 + + def test_web_accepts_bridge_input_and_output_gain(self): + args = _build_parser().parse_args( + [ + "web", + "--bridge-input-gain-db", + "3.0", + "--bridge-output-gain-db", + "-10.0", + ] + ) + + assert args.web_bridge_input_gain_db == 3.0 + assert args.web_bridge_output_gain_db == -10.0 + + class TestWebRigctldDefault: """Issue #1089: rigctld is on by default with --no-rigctld opt-out.""" diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 43b4bf01b..9b28f384e 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -808,6 +808,8 @@ def _web_cmd_args(*, web_bridge: str | None) -> argparse.Namespace: web_bridge=web_bridge, web_bridge_tx_device=None, web_bridge_rx_only=False, + web_bridge_input_gain_db=0.0, + web_bridge_output_gain_db=0.0, web_bridge_label=None, web_bridge_max_retries=1, web_bridge_retry_delay=0.1, @@ -1048,6 +1050,32 @@ async def serve_forever(self, *, on_started=None): assert "loopback not found, bridge disabled" in out +@pytest.mark.asyncio +async def test_cli_web_forwards_bridge_gain_values() -> None: + radio = AsyncMock() + captured: dict[str, object] = {} + + class FakeWebServer: + def __init__(self, _radio, _cfg): + pass + + async def start_audio_bridge(self, **kwargs): + captured.update(kwargs) + + async def serve_forever(self, *, on_started=None): + on_started() + raise asyncio.CancelledError + + args = _web_cmd_args(web_bridge="Loopback") + args.web_bridge_input_gain_db = 6.0 + args.web_bridge_output_gain_db = -10.0 + with patch("rigplane.web.server.WebServer", FakeWebServer): + assert await _cmd_web(radio, args) == 0 + + assert captured["input_gain_db"] == 6.0 + assert captured["output_gain_db"] == -10.0 + + @pytest.mark.asyncio async def test_cli_web_unrelated_bridge_failure_surfaces( capsys: pytest.CaptureFixture[str], @@ -1588,6 +1616,7 @@ def _fake_bridge_cls(start_err: Exception | None = None) -> type: """Return a fake AudioBridge class for testing.""" start_mock = AsyncMock(side_effect=start_err) stop_mock = AsyncMock() + init_kwargs: list[dict[str, object]] = [] _stats = { "running": False, "rx_frames": 5, @@ -1604,7 +1633,7 @@ class _FakeBridge: stop = stop_mock def __init__(self, *_a: object, **_kw: object) -> None: - pass + init_kwargs.append(_kw) @property def stats(self) -> dict: @@ -1612,6 +1641,7 @@ def stats(self) -> dict: _FakeBridge._start_mock = start_mock # type: ignore[attr-defined] _FakeBridge._stop_mock = stop_mock # type: ignore[attr-defined] + _FakeBridge._init_kwargs = init_kwargs # type: ignore[attr-defined] return _FakeBridge @@ -1656,7 +1686,13 @@ async def test_cmd_audio_bridge_list_devices_import_error() -> None: async def test_cmd_audio_bridge_standalone_runs_until_cancelled() -> None: """Bridge starts, runs, and stops cleanly when cancelled (standalone mode).""" radio = _make_audio_capable_mock(MagicMock()) - args = argparse.Namespace(list_devices=False, device="BlackHole 2ch", rx_only=False) + args = argparse.Namespace( + list_devices=False, + device="BlackHole 2ch", + rx_only=False, + input_gain_db=3.0, + output_gain_db=-10.0, + ) fake_cls = _fake_bridge_cls() with patch.object(_ab_mod, "AudioBridge", fake_cls): @@ -1666,6 +1702,8 @@ async def test_cmd_audio_bridge_standalone_runs_until_cancelled() -> None: rc = await asyncio.gather(task, return_exceptions=True) assert rc[0] == 0 + assert fake_cls._init_kwargs[0]["input_gain_db"] == 3.0 + assert fake_cls._init_kwargs[0]["output_gain_db"] == -10.0 fake_cls._start_mock.assert_called_once() fake_cls._stop_mock.assert_called_once() diff --git a/tests/test_web_api_contract.py b/tests/test_web_api_contract.py index 5a741c5e7..3696461ef 100644 --- a/tests/test_web_api_contract.py +++ b/tests/test_web_api_contract.py @@ -309,6 +309,7 @@ def test_pro_web_api_contract_lists_stable_surface() -> None: assert ("PUT", "/api/v1/managed-transmit/tot") in http assert ("GET", "/api/v1/bridge") in http assert ("POST", "/api/v1/bridge") in http + assert ("PATCH", "/api/v1/bridge") in http assert ("DELETE", "/api/v1/bridge") in http assert ("POST", "/api/v1/commands") in http assert ("POST", "/api/v1/commands/batch") in http diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index bf460bccf..1677f2966 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect import io import json import pathlib @@ -130,6 +131,25 @@ def _response_json(writer: _FakeWriter) -> tuple[int, dict]: return status, json.loads(text[body_start:] or "{}") +class _AdjustableBridge: + def __init__(self, *, running: bool = True) -> None: + self.running = running + self.input_gain_db = 0.0 + self.output_gain_db = 0.0 + self.stats = {"running": running, "rx_frames": 3} + + def set_gains( + self, + *, + input_gain_db: float | None = None, + output_gain_db: float | None = None, + ) -> None: + if input_gain_db is not None: + self.input_gain_db = input_gain_db + if output_gain_db is not None: + self.output_gain_db = output_gain_db + + def _drain_queue(queue: asyncio.Queue[object]) -> None: while True: try: @@ -1922,6 +1942,129 @@ async def test_runtime_endpoint_reports_process_bind_radio_and_bridge_status() - srv._server = None # noqa: SLF001 +@pytest.mark.parametrize( + "method_name", ["start_audio_bridge", "defer_audio_bridge_start"] +) +def test_bridge_methods_preserve_legacy_positional_arguments(method_name) -> None: + srv = WebServer(None) + bound = inspect.signature(getattr(srv, method_name)).bind( + "rx-device", "tx-device", False, "legacy-label", 3, 0.25 + ) + bound.apply_defaults() + + assert bound.arguments == { + "device_name": "rx-device", + "tx_device_name": "tx-device", + "tx_enabled": False, + "label": "legacy-label", + "max_retries": 3, + "retry_base_delay": 0.25, + "input_gain_db": 0.0, + "output_gain_db": 0.0, + } + + +def test_deferred_bridge_preserves_legacy_positional_configuration() -> None: + srv = WebServer(None) + + srv.defer_audio_bridge_start( + "rx-device", "tx-device", False, "legacy-label", 3, 0.25 + ) + + assert srv._deferred_bridge_start == { # noqa: SLF001 + "device_name": "rx-device", + "tx_device_name": "tx-device", + "tx_enabled": False, + "label": "legacy-label", + "max_retries": 3, + "retry_base_delay": 0.25, + "input_gain_db": 0.0, + "output_gain_db": 0.0, + } + + +@pytest.mark.asyncio +async def test_bridge_patch_updates_running_gain_and_get_reports_values() -> None: + srv = WebServer(None, WebConfig(host="127.0.0.1", port=0)) + bridge = _AdjustableBridge() + srv._audio_bridge = bridge # type: ignore[assignment] # noqa: SLF001 + payload = json.dumps({"outputGainDb": -10.0}).encode() + patch_writer = _FakeWriter() + + await srv._handle_http( # noqa: SLF001 + patch_writer, + "PATCH", + "/api/v1/bridge", + headers={"content-length": str(len(payload))}, + reader=_reader_with(payload), + ) + + status, data = _response_json(patch_writer) + assert status == 200 + assert data == {"inputGainDb": 0.0, "outputGainDb": -10.0} + assert bridge.output_gain_db == -10.0 + + get_writer = _FakeWriter() + await srv._handle_http( # noqa: SLF001 + get_writer, "GET", "/api/v1/bridge", headers={} + ) + get_status, get_data = _response_json(get_writer) + assert get_status == 200 + assert get_data["inputGainDb"] == 0.0 + assert get_data["outputGainDb"] == -10.0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "expected_status"), + [ + ({}, 400), + ({"inputGainDb": "loud"}, 400), + ({"outputGainDb": float("nan")}, 400), + ({"inputGainDb": True}, 400), + ({"outputGainDb": float("inf")}, 400), + ({"inputGainDb": 10**400}, 400), + ({"unknownGainDb": 1.0}, 400), + ], +) +async def test_bridge_patch_rejects_invalid_gain_payloads( + payload: dict[str, object], expected_status: int +) -> None: + srv = WebServer(None, WebConfig(host="127.0.0.1", port=0)) + srv._audio_bridge = _AdjustableBridge() # type: ignore[assignment] # noqa: SLF001 + body = json.dumps(payload).encode() + writer = _FakeWriter() + + await srv._handle_http( # noqa: SLF001 + writer, + "PATCH", + "/api/v1/bridge", + headers={"content-length": str(len(body))}, + reader=_reader_with(body), + ) + + status, _data = _response_json(writer) + assert status == expected_status + + +@pytest.mark.asyncio +async def test_bridge_patch_requires_running_bridge() -> None: + srv = WebServer(None, WebConfig(host="127.0.0.1", port=0)) + payload = json.dumps({"inputGainDb": 3.0}).encode() + writer = _FakeWriter() + + await srv._handle_http( # noqa: SLF001 + writer, + "PATCH", + "/api/v1/bridge", + headers={"content-length": str(len(payload))}, + reader=_reader_with(payload), + ) + + status, _data = _response_json(writer) + assert status == 409 + + @pytest.mark.asyncio async def test_station_endpoint_reports_selection_metadata_and_guidance() -> None: serial_radio = SimpleNamespace(