Solaar/lib/logitech_receiver/logivoice.py

301 lines
12 KiB
Python

## 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-<slug>-<field>`).
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)