301 lines
12 KiB
Python
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)
|