Skip to main content

hidpp/feature/
equalizer.rs

1//! Implements the `Equalizer` feature (ID `0x8310`, version 2) that configures
2//! an audio device's equalizer (per-band gains) and microphone noise reduction.
3//!
4//! The device exposes a single EQ table of `band_count` frequency bands; each
5//! band has a fixed frequency (Hz) and an adjustable signed gain (dB). All
6//! frequencies are big-endian `u16`; gains are signed `i8`.
7
8#[cfg(test)]
9mod tests;
10
11use num_enum::{IntoPrimitive, TryFromPrimitive};
12use openlogi_hidpp_derive::Feature;
13
14use crate::{feature::FeatureEndpoint, protocol::v20::Hidpp20Error};
15
16/// Maximum number of frequencies a single `getFrequencies` response carries.
17const FREQUENCIES_PER_PAGE: u8 = 7;
18
19bitflags::bitflags! {
20    /// How a device stores its EQ values, from
21    /// [`get_eq_info`](EqualizerFeature::get_eq_info).
22    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
24    pub struct EqCapabilities: u8 {
25        /// EQ values are stored as gains.
26        const STORED_AS_GAINS = 1 << 0;
27        /// EQ values are stored as coefficients.
28        const STORED_AS_COEFFICIENTS = 1 << 1;
29    }
30}
31
32/// Where [`get_frequency_gains`](EqualizerFeature::get_frequency_gains) reads from.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize))]
35#[non_exhaustive]
36#[repr(u8)]
37pub enum GainLocation {
38    /// The custom EQ stored in EEPROM (the version-0 default).
39    Eeprom = 0,
40    /// The active EQ in RAM.
41    Ram = 1,
42}
43
44/// How [`set_frequency_gains`](EqualizerFeature::set_frequency_gains) persists
45/// the gains.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize))]
48#[non_exhaustive]
49#[repr(u8)]
50pub enum GainPersistence {
51    /// Volatile: applied to RAM only.
52    Volatile = 0,
53    /// Applied to RAM and stored in EEPROM.
54    VolatileAndNonVolatile = 1,
55    /// Stored in EEPROM only.
56    NonVolatileOnly = 2,
57}
58
59/// EQ table information from [`get_eq_info`](EqualizerFeature::get_eq_info).
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize))]
62#[non_exhaustive]
63pub struct EqInfo {
64    /// Number of frequency bands.
65    pub band_count: u8,
66    /// Gain range in dB; used as `±db_range` when `db_min`/`db_max` are both `0`.
67    pub db_range: u8,
68    /// How EQ values are stored.
69    pub capabilities: EqCapabilities,
70    /// Minimum gain in dB, or `0` to imply `-db_range`.
71    pub db_min: i8,
72    /// Maximum gain in dB, or `0` to imply `+db_range`.
73    pub db_max: i8,
74}
75
76impl EqInfo {
77    fn from_payload(payload: &[u8; 16]) -> Self {
78        Self {
79            band_count: payload[0],
80            db_range: payload[1],
81            capabilities: EqCapabilities::from_bits_retain(payload[2]),
82            db_min: payload[3].cast_signed(),
83            db_max: payload[4].cast_signed(),
84        }
85    }
86
87    /// The effective `(min, max)` gain range in dB.
88    ///
89    /// Resolves the "both zero implies `±db_range`" rule into concrete bounds.
90    #[must_use]
91    pub fn effective_range(&self) -> (i8, i8) {
92        if self.db_min == 0 && self.db_max == 0 {
93            let range = i8::try_from(self.db_range).unwrap_or(i8::MAX);
94            (-range, range)
95        } else {
96            (self.db_min, self.db_max)
97        }
98    }
99}
100
101/// Implements the `Equalizer` / `0x8310` feature.
102#[derive(Clone, Feature)]
103#[creatable(id = 0x8310, version = 2)]
104pub struct EqualizerFeature {
105    /// The endpoint this feature talks to.
106    endpoint: FeatureEndpoint,
107}
108
109impl EqualizerFeature {
110    /// Retrieves the EQ table's band count, gain range and storage capabilities.
111    pub async fn get_eq_info(&self) -> Result<EqInfo, Hidpp20Error> {
112        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
113        Ok(EqInfo::from_payload(&payload))
114    }
115
116    /// Retrieves the frequency (Hz) of every band.
117    ///
118    /// `band_count` is the value from [`EqInfo::band_count`]; the device returns
119    /// up to seven frequencies per response, so this pages through them until all
120    /// `band_count` are collected.
121    pub async fn get_frequencies(&self, band_count: u8) -> Result<Vec<u16>, Hidpp20Error> {
122        let mut frequencies = Vec::with_capacity(usize::from(band_count));
123        let mut index = 0u8;
124        while index < band_count {
125            let payload = self.endpoint.call(1, [index, 0, 0]).await?.extend_payload();
126            // The response echoes the requested band index in byte 0.
127            if payload[0] != index {
128                return Err(Hidpp20Error::UnsupportedResponse);
129            }
130            let page = (band_count - index).min(FREQUENCIES_PER_PAGE);
131            frequencies.extend(parse_frequency_page(&payload, page)?);
132            index += page;
133        }
134        Ok(frequencies)
135    }
136
137    /// Retrieves the active gain (dB) of every band from `location`.
138    ///
139    /// `band_count` is the value from [`EqInfo::band_count`] (at most 15, the
140    /// number of gains a single response carries).
141    pub async fn get_frequency_gains(
142        &self,
143        location: GainLocation,
144        band_count: u8,
145    ) -> Result<Vec<i8>, Hidpp20Error> {
146        let payload = self
147            .endpoint
148            .call(2, [location.into(), 0, 0])
149            .await?
150            .extend_payload();
151        parse_gains(&payload, 0, band_count)
152    }
153
154    /// Sets the per-band gains (dB) and returns the device's echo of them.
155    ///
156    /// `gains` holds one signed value per band (at most 15). The device rejects
157    /// out-of-range gains.
158    pub async fn set_frequency_gains(
159        &self,
160        persistence: GainPersistence,
161        gains: &[i8],
162    ) -> Result<Vec<i8>, Hidpp20Error> {
163        let count = u8::try_from(gains.len()).map_err(|_| Hidpp20Error::UnsupportedResponse)?;
164        let mut args = [0; 16];
165        args[0] = persistence.into();
166        // Gains follow the persistence byte; each is a signed value sent as a raw
167        // byte.
168        for (i, &gain) in gains.iter().enumerate() {
169            let slot = 1 + i;
170            if slot >= args.len() {
171                return Err(Hidpp20Error::UnsupportedResponse);
172            }
173            args[slot] = gain.cast_unsigned();
174        }
175        let payload = self.endpoint.call_long(3, args).await?.extend_payload();
176        // The response echoes the request, so the gains start after the echoed
177        // persistence byte.
178        parse_gains(&payload, 1, count)
179    }
180
181    /// Retrieves whether hardware microphone noise reduction is enabled.
182    pub async fn get_mic_noise_reduction(&self) -> Result<bool, Hidpp20Error> {
183        let payload = self.endpoint.call(4, [0; 3]).await?.extend_payload();
184        Ok(payload[0] != 0)
185    }
186
187    /// Enables or disables hardware microphone noise reduction.
188    pub async fn set_mic_noise_reduction(&self, enabled: bool) -> Result<(), Hidpp20Error> {
189        self.endpoint.call(5, [u8::from(enabled), 0, 0]).await?;
190        Ok(())
191    }
192}
193
194/// Parses `count` big-endian `u16` frequencies from a `getFrequencies` response,
195/// which carries them starting at byte 1 (after the echoed band index).
196fn parse_frequency_page(payload: &[u8; 16], count: u8) -> Result<Vec<u16>, Hidpp20Error> {
197    let count = usize::from(count);
198    if 1 + 2 * count > payload.len() {
199        return Err(Hidpp20Error::UnsupportedResponse);
200    }
201    Ok((0..count)
202        .map(|i| u16::from_be_bytes([payload[1 + 2 * i], payload[2 + 2 * i]]))
203        .collect())
204}
205
206/// Parses `count` signed gains from a payload starting at `offset`.
207fn parse_gains(payload: &[u8; 16], offset: usize, count: u8) -> Result<Vec<i8>, Hidpp20Error> {
208    let count = usize::from(count);
209    if offset + count > payload.len() {
210        return Err(Hidpp20Error::UnsupportedResponse);
211    }
212    Ok(payload[offset..offset + count]
213        .iter()
214        .map(|&byte| byte.cast_signed())
215        .collect())
216}