## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ ## ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by ## the Free Software Foundation; either version 2 of the License, or ## (at your option) any later version. ## ## This program is distributed in the hope that it will be useful, ## but WITHOUT ANY WARRANTY; without even the implied warranty of ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## GNU General Public License for more details. ## ## You should have received a copy of the GNU General Public License along ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """LogiVoice (0x0900 + 0x0901-0x0907) read helpers. Each LogiVoice processing module exposes the same 5-function API: fn 0 SetState fn 1 GetState -> u8 state (boolean) fn 2 SetParameters fn 3 GetParameters -> module-specific payload (see PARAMETERS_FIELDS) fn 4 GetInfo -> per-field [min, max] bounds (see parse_info) All multi-byte integers on the wire are big-endian. Parameters layouts are module-specific; PARAMETERS_FIELDS encodes per-field offset / width / signedness / range / label metadata. The first field is at offset 0 — there is no leading "state" byte (the state toggle is on fn 0/1 only). Writes are NOT implemented yet. State toggles via fn 0x00/0x10 are shipping as boolean settings; per-field Parameters writes need a live round-trip verification before they're safe to expose. """ from __future__ import annotations import logging import struct from typing import Iterable from .hidpp20_constants import SupportedFeature logger = logging.getLogger(__name__) # Wire function IDs (standard across all LogiVoice modules). FN_SET_STATE = 0x00 FN_GET_STATE = 0x10 FN_SET_PARAMETERS = 0x20 FN_GET_PARAMETERS = 0x30 FN_GET_INFO = 0x40 # Human-readable names for the modules Solaar may see on a LogiVoice device. MODULE_NAMES = { SupportedFeature.LOGIVOICE: "LogiVoice", SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "Noise Reduction", SupportedFeature.LOGIVOICE_NOISE_GATE: "Noise Gate", SupportedFeature.LOGIVOICE_COMPRESSOR: "Compressor", SupportedFeature.LOGIVOICE_DE_ESSER: "De-esser", SupportedFeature.LOGIVOICE_DE_POPPER: "De-popper", SupportedFeature.LOGIVOICE_LIMITER: "Limiter", SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "High Pass Filter", } # Short slugs used in Solaar setting IDs (`logivoice--`). MODULE_SLUGS = { SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "nr", SupportedFeature.LOGIVOICE_NOISE_GATE: "ng", SupportedFeature.LOGIVOICE_COMPRESSOR: "comp", SupportedFeature.LOGIVOICE_DE_ESSER: "deesser", SupportedFeature.LOGIVOICE_DE_POPPER: "depopper", SupportedFeature.LOGIVOICE_LIMITER: "limiter", SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "hpf", } class Field: """Metadata for one decoded Parameters field. offset: byte offset within the GetParameters payload. byte_count: width (1 or 2 for fields we currently decode). signed: whether to interpret as signed int. min_value/max_value: range for the Solaar slider validator. For opaque fields, use the full representable range (0..255 or 0..65535). label: human-readable name for UI. opaque: True if the field's wire encoding isn't pinned down — label shows raw units and the caller should treat as round-trip. """ def __init__(self, name, offset, byte_count, signed, min_value, max_value, label, opaque=False): self.name = name self.offset = offset self.byte_count = byte_count self.signed = signed self.min_value = min_value self.max_value = max_value self.label = label self.opaque = opaque # Per-module field layout for GetParameters / SetParameters payload. Each # module's struct is the union of named fields below; there is no separate # "state" byte at offset 0 — that toggle is only on fn 0x00/0x10. Field # encodings (signedness, byte order, units) and value ranges come from the # device's GetInfo response (see parse_info) and are confirmed against # captured bring-up bytes; ranges hardcoded here are the bounds the device # reports and the values it ships as factory defaults. # # `opaque=True` is reserved for fields whose unit scale isn't pinned down # (currently width_q on De-esser / De-popper — the host-side scale constant # is loaded at runtime and not statically resolvable). Treat opaque values # as monotonic raw integers until a live probe anchors the units. PARAMETERS_FIELDS: dict[SupportedFeature, list[Field]] = { SupportedFeature.LOGIVOICE_NOISE_REDUCTION: [ Field("sensitivity", 0, 1, False, 0, 40, "Sensitivity"), Field("release", 1, 2, False, 1, 1000, "Release (ms)"), Field("bias", 3, 1, False, 0, 5, "Bias"), Field("attenuation", 4, 1, True, -20, 0, "Attenuation (dB)"), ], SupportedFeature.LOGIVOICE_NOISE_GATE: [ Field("threshold", 0, 1, True, -60, -35, "Threshold (dB)"), Field("attenuation", 1, 1, True, -50, -3, "Attenuation (dB)"), Field("attack", 2, 2, False, 1, 200, "Attack (ms)"), Field("hold", 4, 2, False, 1, 1000, "Hold (ms)"), Field("release", 6, 2, False, 1, 1000, "Release (ms)"), ], SupportedFeature.LOGIVOICE_COMPRESSOR: [ Field("threshold", 0, 1, True, -40, 0, "Threshold (dB)"), Field("attack", 1, 2, False, 1, 200, "Attack (ms)"), Field("release", 3, 2, False, 50, 1000, "Release (ms)"), Field("post_gain", 5, 1, True, -12, 12, "Post Gain (dB)"), Field("pre_gain", 6, 1, True, -12, 12, "Pre Gain (dB)"), # Ratio reports min=1 max=20 from GetInfo; whether the device interprets # it as a literal X:1 ratio or a curve-table index is unconfirmed. Field("ratio", 7, 1, False, 1, 20, "Ratio"), ], SupportedFeature.LOGIVOICE_DE_ESSER: [ Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"), Field("frequency", 1, 2, False, 1000, 10000, "Frequency (Hz)"), # width_q is a Q-format quantization with a device-loaded scale we # don't know; range/default come straight from GetInfo. Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True), Field("attack", 4, 2, False, 1, 200, "Attack (ms)"), Field("release", 6, 2, False, 20, 1000, "Release (ms)"), Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"), ], SupportedFeature.LOGIVOICE_DE_POPPER: [ Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"), Field("frequency", 1, 2, False, 60, 500, "Frequency (Hz)"), Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True), Field("attack", 4, 2, False, 1, 200, "Attack (ms)"), Field("release", 6, 2, False, 20, 1000, "Release (ms)"), Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"), ], SupportedFeature.LOGIVOICE_LIMITER: [ Field("boost", 0, 1, True, -128, 127, "Boost (dB)"), Field("attack", 1, 2, False, 1, 65535, "Attack (ms)"), Field("release", 3, 2, False, 1, 65535, "Release (ms)"), ], SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: [ Field("frequency", 0, 2, False, 60, 300, "Cutoff (Hz)"), ], } def expected_payload_length(feature: SupportedFeature) -> int: fields = PARAMETERS_FIELDS.get(feature) if not fields: return 0 return max(f.offset + f.byte_count for f in fields) def get_state(device, feature: SupportedFeature): """Read the module's on/off state via fn 1. Returns int 0-255 or None.""" result = device.feature_request(feature, FN_GET_STATE) if result is None or len(result) < 1: return None return result[0] def get_parameters(device, feature: SupportedFeature): """Read the module's Parameters struct via fn 3. Returns raw bytes or None.""" result = device.feature_request(feature, FN_GET_PARAMETERS) if result is None: return None return bytes(result) def get_info(device, feature: SupportedFeature): """Read module capability info via fn 4. Returns raw bytes or None. Decoded per-field bounds are available via parse_info(). """ result = device.feature_request(feature, FN_GET_INFO) if result is None: return None return bytes(result) def _decode_field(chunk: bytes, byte_count: int, signed: bool) -> int: """Decode `byte_count` bytes from `chunk` as an integer per the field's wire encoding. Multi-byte values are big-endian (matches Parameters).""" if byte_count == 1: return struct.unpack("b" if signed else "B", chunk[:1])[0] if byte_count == 2: return struct.unpack(">h" if signed else ">H", chunk[:2])[0] return int.from_bytes(chunk[:byte_count], "big", signed=signed) def parse_info(feature: SupportedFeature, payload: bytes) -> dict: """Decode a GetInfo response into per-field {min, max} bounds. Layout: for each field in PARAMETERS_FIELDS in order, the payload carries [min_value, max_value] back-to-back using the field's wire encoding (so a u16 field contributes 4 bytes — 2 for min, 2 for max). Trailing bytes in the response are pad/zero. Returns a dict mapping field name to {"min": int, "max": int}. Fields that don't fit in the payload are omitted. """ fields = PARAMETERS_FIELDS.get(feature) if not fields or not payload: return {} out = {} offset = 0 for f in fields: end = offset + 2 * f.byte_count if end > len(payload): break min_val = _decode_field(payload[offset : offset + f.byte_count], f.byte_count, f.signed) max_val = _decode_field(payload[offset + f.byte_count : end], f.byte_count, f.signed) out[f.name] = {"min": min_val, "max": max_val} offset = end return out def parse_parameters(feature: SupportedFeature, payload: bytes) -> dict: """Decode Parameters bytes into a dict per the per-module field table. Returns {} on unknown feature or short payload — caller still has the raw hex via get_parameters() for corpus logging. """ fields = PARAMETERS_FIELDS.get(feature) if not fields or payload is None: return {} parsed = {} for f in fields: end = f.offset + f.byte_count if end > len(payload): continue chunk = payload[f.offset : end] if f.byte_count == 1: val = struct.unpack("b" if f.signed else "B", chunk)[0] elif f.byte_count == 2: val = struct.unpack(">h" if f.signed else ">H", chunk)[0] else: val = int.from_bytes(chunk, "big", signed=f.signed) parsed[f.name] = val return parsed def probe_module(device, feature: SupportedFeature) -> None: """One-shot corpus probe. Logs state + raw parameters + parsed + raw info + decoded info bounds.""" name = MODULE_NAMES.get(feature, f"0x{int(feature):04X}") state = get_state(device, feature) params = get_parameters(device, feature) info = get_info(device, feature) logger.debug( "LogiVoice %s [0x%04X]: state=%s parameters=%s info=%s", name, int(feature), state, params.hex() if params else None, info.hex() if info else None, ) parsed = parse_parameters(feature, params) if params else {} if parsed: logger.debug("LogiVoice %s parsed: %s", name, parsed) bounds = parse_info(feature, info) if info else {} if bounds: logger.debug("LogiVoice %s info bounds: %s", name, bounds) def probe_all_modules(device, features: Iterable[SupportedFeature]) -> None: """Probe every LogiVoice module present on the device. Call once at device-bring-up so the -dd corpus has a full snapshot. Caller passes whichever subset of LogiVoice features are actually discovered (usually derived from device.features). """ for feature in features: if feature not in PARAMETERS_FIELDS and feature != SupportedFeature.LOGIVOICE: continue try: probe_module(device, feature) except Exception as e: logger.debug("LogiVoice probe_module(%s) raised %s", feature, e)