diff --git a/README.md b/README.md index f5bf269d..0a0302ac 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,8 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | HyperX Cloud 3 | All | x | | | | | | | | | | | | | | | | | | | ROCCAT Elo 7.1 Air | All | | | | x | x | | | | | | | | | | | | | | | ROCCAT Elo 7.1 USB | All | | | | x | | | | | | | | | | | | | | | -| Audeze Maxwell | All | x | x | | | x | x | x | | x | | | | | x | | | x | | -| Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | | +| Audeze Maxwell | All | x | x | | | x | x | x | | x | x | | | | x | | | x | x | +| Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | x | | Lenovo Wireless VoIP Headset | All | x | x | | | x | | x | x | x | | | x | | x | | | | | | Plantronics Voyager 8200 UC (BT600) | L/W | x | x | | x | | | x | | | | | | | x | | | | | | Jabra Link 390 (paired headset) | L/M | x | x | | x | x | | x | | | | | | | x | | | | x | diff --git a/lib/devices/audeze_maxwell.hpp b/lib/devices/audeze_maxwell.hpp index 0b70820f..eabd71e9 100644 --- a/lib/devices/audeze_maxwell.hpp +++ b/lib/devices/audeze_maxwell.hpp @@ -1,11 +1,19 @@ #pragma once -#include "../result_types.hpp" -#include "hid_device.hpp" +#include "audeze_maxwell_device.hpp" +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include -#include +#include using namespace std::string_view_literals; @@ -15,59 +23,52 @@ namespace headsetcontrol { * @brief Audeze Maxwell Gaming Headset * * Features: - * - Sidetone (32 levels) + * - Sidetone (level read from the headset, 32 levels by default) * - Inactive time with discrete levels * - Volume limiter * - Equalizer presets (10 total: 6 default + 4 custom) + * - Custom EQ (ten whole-dB gains, edits the selected custom preset) * - Battery status * - Chatmix * - Voice prompts * - Mic noise filter * - * Special Requirements: - * - 14-packet initialization sequence followed by 6 status requests - * - 60ms delay between packets (official software sends at 52ms but 60ms is more reliable in testing) - * - Uses hid_get_input_report for responses + * The protocol is implemented in audeze_maxwell_device.hpp. The custom EQ adds these + * commands: + * + * read stored data 0C 0A E8 03 -> 5B: 0C 0A + * reclaim storage 03 0A -> 5B: 03 0A + * store data 0D 0A -> 5B: 0D 0A + * apply to the DSP 03 0E 00 -> 5B: 03 0E + * + * and are 16-bit, low byte first, and is 00 on success. The + * byte in the reclaim reply is not a status; the dongle sends 01 in normal operation. + * + * Each custom preset has two stored items: the settings the Audeze app shows + * (EF00 + slot) and the DSP coefficients computed from them (E42C + slot). */ -class AudezeMaxwell : public HIDDevice { +class AudezeMaxwell : public AudezeMaxwellDevice { public: static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x4b19, // Maxwell 0x4b18, // Maxwell Xbox Dongle }; - static constexpr int MSG_SIZE = 62; - static constexpr int REPORT_ID = 0x06; - static constexpr int DELAY_US = 60000; // 60ms - - // 14-packet initialization sequence - static constexpr std::array, 14> UNIQUE_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x20 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x25 }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x28 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x01 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 }, - { 0x06, 0x07, 0x00, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2D }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2F }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0xD6, 0x0C } } }; - - // 6 packet status request sequence - static constexpr std::array, 6> STATUS_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x22 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 } } }; - - constexpr uint16_t getVendorId() const override - { - return 0x3329; // Audeze - } + static constexpr uint8_t VOLUME_LIMITER_ON = 0x88; + static constexpr uint8_t VOLUME_LIMITER_OFF = 0x8E; + + // Presets as the headset numbers them (1-10); the last four are the custom ones + static constexpr uint8_t FIRST_CUSTOM_PRESET = 7; + static constexpr uint8_t LAST_CUSTOM_PRESET = 10; + + static constexpr Command CMD_EQ_APPLY { 0x03, 0x0E }; + static constexpr Command CMD_STORAGE_RECLAIM { 0x03, 0x0A }; + static constexpr Command CMD_STORAGE_READ { 0x0C, 0x0A }; + static constexpr Command CMD_STORAGE_WRITE { 0x0D, 0x0A }; + + static constexpr uint16_t STORAGE_KEY_EQ_SETTINGS = 0xEF00; + static constexpr uint16_t STORAGE_KEY_EQ_COEFFICIENTS = 0xE42C; + static constexpr uint16_t STORAGE_READ_MAX_LENGTH = 1000; std::vector getProductIds() const override { @@ -83,7 +84,7 @@ class AudezeMaxwell : public HIDDevice { { return B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_VOLUME_LIMITER) | B(CAP_EQUALIZER_PRESET) | B(CAP_BATTERY_STATUS) | B(CAP_CHATMIX_STATUS) - | B(CAP_VOICE_PROMPTS) | B(CAP_NOISE_FILTER); + | B(CAP_VOICE_PROMPTS) | B(CAP_NOISE_FILTER) | B(CAP_SIDETONE_STATUS) | B(CAP_EQUALIZER); } constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override @@ -91,305 +92,487 @@ class AudezeMaxwell : public HIDDevice { return { .usagepage = 0xff13, .usageid = 0x1, .interface_id = 0 }; } - static constexpr int EQUALIZER_PRESETS_COUNT = 10; + Result setVolumeLimiter(hid_device* device_handle, bool enabled) override + { + if (auto result = writeKey(device_handle, KEY_VOLUME_LIMITER, enabled ? VOLUME_LIMITER_ON : VOLUME_LIMITER_OFF); !result) { + return result.error(); + } + + return VolumeLimiterResult { .enabled = enabled }; + } - uint8_t getEqualizerPresetsCount() const override + std::optional getEqualizerInfo() const override { - return EQUALIZER_PRESETS_COUNT; + return EqualizerInfo { + .bands_count = static_cast(EQ_FREQUENCIES.size()), + .bands_baseline = 0, + .bands_step = 1.0f, + .bands_min = static_cast(EQ_GAIN_MIN), + .bands_max = static_cast(EQ_GAIN_MAX) + }; } -private: /** - * @brief Send request and get input report with 60ms delay + * @brief Change the gains of the custom preset that is currently selected * - * Audeze HQ sends packets at ~60ms intervals. Sending too quickly causes issues. + * Follows Audeze.exe writeEqBands: apply the new coefficients, store the settings, + * store the coefficients. Each command is sent exactly once. A later failure can + * leave the active EQ or one stored item changed, so it is reported and never + * presented as an atomic update. Both stored items are read back at the end. */ - Result sendGetInputReport(hid_device* device_handle, std::span data, std::span buff = {}) const + Result setEqualizer(hid_device* device_handle, const EqualizerSettings& settings) override { - std::this_thread::sleep_for(std::chrono::microseconds(DELAY_US)); + if (settings.bands.size() != EQ_FREQUENCIES.size() || !std::ranges::all_of(settings.bands, isSupportedEqGain)) { + return DeviceError::invalidParameter("Maxwell EQ needs ten whole-dB gains from -12 to 12"); + } - if (auto result = writeHID(device_handle, data, MSG_SIZE); !result) { - return result.error(); + auto slot = readSelectedCustomSlot(device_handle); + if (!slot) { + return slot.error(); } + const auto settings_key = static_cast(STORAGE_KEY_EQ_SETTINGS + *slot); + const auto coefficients_key = static_cast(STORAGE_KEY_EQ_COEFFICIENTS + *slot); - if (buff.empty()) { - std::array tempBuff { 0x7 }; - auto read_result = getInputReport(device_handle, tempBuff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } - } else { - buff[0] = 0x7; - auto read_result = getInputReport(device_handle, buff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } + // Only the gains are replaced; everything else the app stored is kept + auto stored_settings = readStoredData(device_handle, settings_key, EQ_SETTINGS_SIZE); + if (!stored_settings) { + return stored_settings.error(); + } + auto bands = readEqBands(*stored_settings); + if (!bands) { + return bands.error(); + } + for (size_t i = 0; i < bands->size(); ++i) { + (*bands)[i].gain = settings.bands[i]; + writeEqBandGain(*stored_settings, i, settings.bands[i]); + } + auto coefficients = eqCoefficients(*bands); + if (!coefficients) { + return coefficients.error(); } - return {}; + if (auto result = applyEq(device_handle, *coefficients); !result) { + return result.error(); + } + if (auto result = storeData(device_handle, settings_key, *stored_settings); !result) { + return result.error(); + } + if (auto result = storeData(device_handle, coefficients_key, *coefficients); !result) { + return result.error(); + } + + if (auto result = verifyStoredData(device_handle, settings_key, *stored_settings, "Maxwell EQ readback differs from the saved settings; update may be partial"); !result) { + return result.error(); + } + if (auto result = verifyStoredData(device_handle, coefficients_key, *coefficients, "Maxwell coefficient readback differs; EQ update may be partial"); !result) { + return result.error(); + } + return EqualizerResult {}; } +protected: + // Non-static so tests can shorten them + std::chrono::milliseconds eq_timeout_ { 1500 }; + std::chrono::milliseconds eq_fragment_interval_ { 20 }; + +private: + // Positions in the replies of the storage commands: 05 5B + static constexpr size_t STORAGE_STATUS_OFFSET = 6; + static constexpr size_t STORAGE_KEY_OFFSET = 7; + static constexpr size_t STORAGE_LENGTH_OFFSET = 9; + static constexpr size_t STORAGE_DATA_OFFSET = 11; + /** - * @brief Get device status (battery, chatmix, sidetone, mic mute) - * - * Must send all 6 status requests to get consistent information + * EQ requests are never repeated: storing twice is not known to be safe. The reports + * are paced because the wireless dongle can silently drop an unpaced burst of long + * command fragments (confirmed by live EQ apply/restore testing). */ - struct MaxwellStatus { - BatteryResult battery; - int chatmix_level; - int sidetone_level; - int eq_setting; - int ainf_level; - bool sidetone_enabled; - bool mic_muted; - }; - - Result getDeviceStatus(hid_device* device_handle) const + RequestOptions eqRequestOptions() const { - std::array, 6> status_buffs {}; + return { .attempts = 1, .timeout = eq_timeout_, .fragment_interval = eq_fragment_interval_ }; + } - // Send UNIQUE_REQUESTS packets - for (auto unique_request : UNIQUE_REQUESTS) { - if (auto result = sendGetInputReport(device_handle, unique_request); !result) { - return result.error(); - } + /// Slot (0-3) of the selected custom preset; an error if a built-in preset is selected + Result readSelectedCustomSlot(hid_device* device_handle) const + { + auto preset = readKey(device_handle, KEY_EQ_PRESET); + if (!preset) { + return preset.error(); } - - // Send STATUS_REQUESTS packets - for (int i = 0; i < 6; ++i) { - if (auto result = sendGetInputReport(device_handle, STATUS_REQUESTS[i], status_buffs[i]); !result) { - return result.error(); - } + if (*preset < FIRST_CUSTOM_PRESET || *preset > LAST_CUSTOM_PRESET) { + return DeviceError::invalidParameter("Select a Maxwell custom preset with -p 6..9 before editing EQ"); } + return static_cast(*preset - FIRST_CUSTOM_PRESET); + } - // Status_buffs structure - // status_buffs[0] = battery_status - // status_buffs[1] = mic_status - // status_buffs[2] = eq - // status_buffs[3] = chatmix - // status_buffs[4] = ainf_level - // status_buffs[5] = sidetone_level - - MaxwellStatus status {}; - - const auto& buff = status_buffs[0]; - - // Initialize battery status as unavailable - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - - // Parse battery level - if (buff[0] == 0x00) { - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - } else { - // Find battery level in response (level is first byte after d6 0c 00 00 in packet) - for (int i = 0; i < MSG_SIZE - 4; ++i) { - if (buff[i] == 0xD6 && buff[i + 1] == 0x0C && buff[i + 2] == 0x00 && buff[i + 3] == 0x00) { - status.battery.level_percent = buff[i + 4]; - status.battery.status = BATTERY_AVAILABLE; - break; - } - } + Result readStoredData(hid_device* device_handle, uint16_t key, size_t expected_size) const + { + auto body = commandBody(CMD_STORAGE_READ); + appendLE16(body, key); + appendLE16(body, STORAGE_READ_MAX_LENGTH); + + // A reply too short to carry a key is accepted so it is reported as invalid below + auto reply = request( + device_handle, body, [key](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_STORAGE_READ) + && (message.size() < STORAGE_LENGTH_OFFSET || hasId(message, STORAGE_KEY_OFFSET, key)); + }, + eqRequestOptions()); + if (!reply) { + return reply.error(); } + if (reply->size() != STORAGE_DATA_OFFSET + expected_size + || (*reply)[STORAGE_STATUS_OFFSET] != STATUS_OK + || readLE16(*reply, STORAGE_LENGTH_OFFSET) != expected_size) { + return DeviceError::protocolError("Maxwell returned an invalid custom EQ slot"); + } + return Message(reply->begin() + STORAGE_DATA_OFFSET, reply->end()); + } - // Parse microphone mute status (FF = unmuted) - status.mic_muted = status_buffs[1][12] != 0xFF; - status.battery.mic_status = status.mic_muted ? MICROPHONE_UP : MICROPHONE_UNKNOWN; - - // Parse current eq setting - // 1 = Audeze, 2 = Treble Boost, 3 = Bass Boost, 4 = Immersive, 5 = Competition, - // 6 = Footsteps, 7 = EQ1, 8 = EQ2, 9 = EQ3, 10 = EQ4 - status.eq_setting = status_buffs[2][12]; - - // Parse chatmix (0-20 range, center at 10) - status.chatmix_level = map(status_buffs[3][12], 0, 20, 0, 128); + /// Read stored data back and compare it with what was written + Result verifyStoredData(hid_device* device_handle, uint16_t key, const Message& expected, std::string_view mismatch) const + { + auto stored = readStoredData(device_handle, key, expected.size()); + if (!stored) { + return stored.error(); + } + if (*stored != expected) { + return DeviceError::protocolError(mismatch); + } + return {}; + } - // Parse current mic noise filter level - // 0 = Off, 1 = Low, 2 = High - status.ainf_level = status_buffs[4][12]; + /** + * @brief Send an EQ command and check its acknowledgement + * + * @param key Storage key the acknowledgement has to repeat, if the command has one + * @param checked Whether byte 6 of the acknowledgement is a status that must be zero + */ + Result eqCommand(hid_device* device_handle, const Message& body, std::string_view operation, + std::optional key = std::nullopt, bool checked = true) const + { + const Command command { body[0], body[1] }; + const size_t reply_size = key ? STORAGE_KEY_OFFSET + 2 : STORAGE_STATUS_OFFSET + 1; + + auto reply = request( + device_handle, body, [command, key](const Message& message) { + return isMessage(message, TYPE_RESPONSE, command) + && (!key || message.size() < STORAGE_LENGTH_OFFSET || hasId(message, STORAGE_KEY_OFFSET, *key)); + }, + eqRequestOptions()); + if (!reply) { + auto error = reply.error(); + error.message = std::format("Maxwell {} failed: {}; EQ update may be partial. Close Audeze before retrying.", operation, error.message); + return error; + } + if (reply->size() != reply_size || (checked && (*reply)[STORAGE_STATUS_OFFSET] != STATUS_OK)) { + return DeviceError::protocolError(std::format("Maxwell rejected {}; EQ update may be partial", operation)); + } + return {}; + } - // Parse sidetone (0-31 range) - status.sidetone_level = map(status_buffs[5][12], 0, 31, 0, 128); - status.sidetone_enabled = status_buffs[5][12] != 0; + /// Make the DSP use new coefficients right away, without storing them + Result applyEq(hid_device* device_handle, std::span coefficients) const + { + auto body = commandBody(CMD_EQ_APPLY, { 0x00 }); + body.insert(body.end(), coefficients.begin(), coefficients.end()); + return eqCommand(device_handle, body, "apply EQ"); + } - return status; + Result reclaimStorage(hid_device* device_handle, size_t length) const + { + auto body = commandBody(CMD_STORAGE_RECLAIM); + appendLE16(body, static_cast(length)); + + // Unlike apply/save, Audeze's reclaim helper does not interpret byte 6 + // as a zero-success status (RVA 24740 / predicate 1B570). The dongle + // replies 01 here during normal operation. Framing and opcode are + // validated; the saves and the byte-for-byte readback catch the rest. + return eqCommand(device_handle, body, "prepare EQ storage", std::nullopt, false); } -public: - // Rich Results V2 API - Result getBattery(hid_device* device_handle) override + /// Store data under a key, reclaiming the space for it first as Audeze.exe does + Result storeData(hid_device* device_handle, uint16_t key, std::span data) const { - auto status_result = getDeviceStatus(device_handle); - if (!status_result) { - return status_result.error(); + if (auto result = reclaimStorage(device_handle, data.size()); !result) { + return result.error(); } - return status_result->battery; + auto body = commandBody(CMD_STORAGE_WRITE); + appendLE16(body, key); + body.insert(body.end(), data.begin(), data.end()); + return eqCommand(device_handle, body, "save EQ", key); } - Result setSidetone(hid_device* device_handle, uint8_t level) override - { - // Maxwell range: 0 to 31 - uint8_t mapped = map(level, 0, 128, 0, 31); + // ------------------------------------------------------------------------ + // Custom EQ data: the settings the Audeze app stores for a custom preset and + // the DSP coefficients computed from them. Static and without HID access, so + // it can be checked on its own. + // ------------------------------------------------------------------------ +public: + static constexpr std::array EQ_FREQUENCIES { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 }; + + static constexpr double EQ_GAIN_MIN = -12; + static constexpr double EQ_GAIN_MAX = 12; + static constexpr double EQ_MAX_BANDWIDTH = 48000; + + // The stored settings are a header followed by one record per band. Only the + // fields below are interpreted; everything else is kept as it is. + static constexpr size_t EQ_HEADER_SIZE = 5; + static constexpr size_t EQ_BAND_RECORD_SIZE = 18; + static constexpr size_t EQ_BAND_FREQUENCY_OFFSET = 2; + static constexpr size_t EQ_BAND_GAIN_OFFSET = 6; + static constexpr size_t EQ_BAND_BANDWIDTH_OFFSET = 10; + static constexpr size_t EQ_SETTINGS_SIZE = EQ_HEADER_SIZE + EQ_BAND_RECORD_SIZE * EQ_FREQUENCIES.size(); + + struct EqBand { + double frequency; + double gain; + double bandwidth; + }; - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, mapped }; + /// True for the gains the app can produce: whole dB within the supported range + static bool isSupportedEqGain(double gain) + { + return std::isfinite(gain) && gain >= EQ_GAIN_MIN && gain <= EQ_GAIN_MAX && std::round(gain) == gain; + } - // Sidetone is enabled whenever level changes - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + /// Parse the bands out of the stored settings + static Result> readEqBands(std::span settings) + { + if (settings.size() != EQ_SETTINGS_SIZE) { + return DeviceError::protocolError("Unexpected Maxwell custom EQ size (expected ten bands)"); } - - if (mapped == 0) { - // Toggle sidetone off - std::array toggle_cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x82, 0x2C, 0x07, 0x00, 0x00 }; - result = sendGetInputReport(device_handle, toggle_cmd); - if (!result) { - return result.error(); + std::vector bands; + for (size_t i = 0; i < EQ_FREQUENCIES.size(); ++i) { + const size_t record = eqBandOffset(i); + EqBand band { + .frequency = readEqBandValue(settings, record + EQ_BAND_FREQUENCY_OFFSET), + .gain = readEqBandValue(settings, record + EQ_BAND_GAIN_OFFSET), + .bandwidth = readEqBandValue(settings, record + EQ_BAND_BANDWIDTH_OFFSET) + }; + if (band.frequency != EQ_FREQUENCIES[i] || band.bandwidth <= 0 || band.bandwidth > EQ_MAX_BANDWIDTH) { + return DeviceError::protocolError("Unsupported Maxwell EQ band layout or bandwidth"); } + bands.push_back(band); } + return bands; + } - return SidetoneResult { - .current_level = level, - .min_level = 0, - .max_level = 128, - .device_min = 0, - .device_max = 31 - }; + /// Store a whole-dB gain in a band's record, leaving the rest of the settings untouched + static void writeEqBandGain(std::span settings, size_t band, double gain) + { + const auto hundredths = static_cast(gain * EQ_VALUES_PER_UNIT); + writeLE32(settings, eqBandOffset(band) + EQ_BAND_GAIN_OFFSET, static_cast(hundredths)); } - Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + /// Coefficient block for one sample rate: band count, five words per band, output gain + static Result eqCoefficientsForRate(std::span bands, int rate) { - std::array cmd { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00 }; - - if (minutes > 0) { - // Round up to the nearest value the headset supports - uint16_t final_minutes = 360; - if (minutes <= 5) - final_minutes = 5; - else if (minutes <= 10) - final_minutes = 10; - else if (minutes <= 15) - final_minutes = 15; - else if (minutes <= 30) - final_minutes = 30; - else if (minutes <= 45) - final_minutes = 45; - else if (minutes <= 60) - final_minutes = 60; - else if (minutes <= 90) - final_minutes = 90; - else if (minutes <= 120) - final_minutes = 120; - else if (minutes <= 240) - final_minutes = 240; - - uint16_t seconds = final_minutes * 60; - - cmd[11] = 0x01; // Inactive time flag - cmd[13] = seconds & 0xFF; // LSB - cmd[14] = (seconds >> 8) & 0xFF; // MSB - cmd[15] = 0x01; // Constant flag - cmd[17] = cmd[13]; // LSB again - cmd[18] = cmd[14]; // MSB again - } - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + const bool known_rate = std::ranges::any_of(EQ_SAMPLE_RATES, [rate](const EqSampleRate& supported) { + return supported.rate == rate; + }); + if (bands.empty() || bands.size() > EQ_FREQUENCIES.size() || !known_rate) { + return DeviceError::invalidParameter("Invalid Maxwell EQ band count or sample rate"); + } + if (!std::ranges::all_of(bands, [rate](const EqBand& band) { return isSupportedEqBand(band, rate); })) { + return DeviceError::invalidParameter("Maxwell EQ requires finite whole-dB gains from -12 to 12"); } - return InactiveTimeResult { - .minutes = minutes, - .min_minutes = 0, - .max_minutes = 255 - }; - } + // The app uses integral dB gains. Its DLL orders descending gain, retaining + // input order for ties. Fractional gains are deliberately not exposed here. + std::vector ordered(bands.begin(), bands.end()); + std::stable_sort(ordered.begin(), ordered.end(), [](const EqBand& a, const EqBand& b) { return a.gain > b.gain; }); + std::vector filters; + for (const auto& band : ordered) { + filters.push_back(peakingFilter(band, rate)); + } - Result setVolumeLimiter(hid_device* device_handle, bool enabled) override - { - std::array cmd { 0x6, 0x9, 0x80, 0x5, 0x5a, 0x5, 0x0, 0x0, 0x9, 0x28, 0x0, enabled ? uint8_t(0x88) : uint8_t(0x8e) }; + const auto peaks = cascadePeaks(filters, rate); + const double peak = peaks.back(); + const double prescale = std::max(1.0, peak / EQ_STAGE_PEAK_LIMIT); + double scale_product = 1; + + Message result; + appendLE16(result, static_cast(bands.size())); + for (size_t i = 0; i < filters.size(); ++i) { + auto filter = filters[i]; + double scale = std::max({ std::abs(filter[COEF_B0]), std::abs(filter[COEF_B1] * 0.5), std::abs(filter[COEF_B2]) }); + const double first_scale = i == 0 ? prescale * EQ_FIRST_STAGE_MARGIN : 1.0; + if (peaks[i] / (scale_product * scale * first_scale) > EQ_STAGE_PEAK_LIMIT) { + scale = std::max(1.0, scale); + } + scale *= first_scale; + scale_product *= scale; - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + // Only the feed-forward coefficients are scaled; all of them are rounded to 24 bits + for (size_t j = 0; j < filter.size(); ++j) { + if (j < COEF_A1) { + filter[j] /= scale; + } + const double quantum = hasHalfResolution(j) ? Q22 : Q23; + filter[j] = std::floor(std::clamp(filter[j] * quantum + 0.5, -Q23, Q23 - 1)) / quantum; + } + if (!isStableFilter(filter)) { + return DeviceError::invalidParameter("Maxwell EQ filter becomes unstable after quantization"); + } + for (size_t j = 0; j < filter.size(); ++j) { + appendDsp32(result, static_cast(filter[j] * (hasHalfResolution(j) ? Q30 : Q31))); + } } - return VolumeLimiterResult { .enabled = enabled }; + // Mode 1, requested rescaling 0 dB. Limit the overall peak, matching + // change_rescale_cofe. Gain is rounded at Q18 and stored at Q26. + const double gain = scale_product * std::min(1.0, EQ_OUTPUT_PEAK_LIMIT / peak); + if (!std::isfinite(gain) || gain <= 0 || gain >= 32) { + return DeviceError::invalidParameter("Maxwell EQ gain exceeds the DSP range"); + } + const auto fixed_gain = static_cast(std::floor(gain * Q18 + 0.5)); + if (fixed_gain > Q23 - 1) { + return DeviceError::invalidParameter("Maxwell EQ rounded gain exceeds the DSP range"); + } + appendDsp32(result, fixed_gain * 256); + return result; } - Result setVoicePrompts(hid_device* device_handle, bool enabled) override + /// Coefficient payload for all sample rates, as applied to the DSP and stored on the headset + static Result eqCoefficients(std::span bands) { - // Voice prompt level: 0 (0%) to 15 (100%), default 7 (50%) - // API doesn't support level, so use 7 when on, 0 when off - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x20, 0x00, enabled ? uint8_t(0x07) : uint8_t(0x00) }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + Message result { static_cast(EQ_SAMPLE_RATES.size()), 0, 0, 0 }; + for (const auto& sample_rate : EQ_SAMPLE_RATES) { + auto block = eqCoefficientsForRate(bands, sample_rate.rate); + if (!block) { + return block.error(); + } + appendLE16(result, sample_rate.id); + appendLE16(result, static_cast(block->size() / 2)); + result.insert(result.end(), block->begin(), block->end()); } + return result; + } - return VoicePromptsResult { .enabled = enabled }; +private: + // Band fields are signed 32-bit little endian values in hundredths (Hz or dB) + static constexpr double EQ_VALUE_SCALE = 0.01; + static constexpr int EQ_VALUES_PER_UNIT = 100; + + // Sample rates the headset needs coefficients for, with the ID each one has on the wire + struct EqSampleRate { + int rate; + uint8_t id; + }; + static constexpr std::array EQ_SAMPLE_RATES { { { 44100, 1 }, { 48000, 2 }, { 88200, 5 }, { 96000, 6 } } }; + + // Fixed point scales of the DSP + static constexpr double Q18 = 262144.0; + static constexpr double Q22 = 4194304.0; + static constexpr double Q23 = 8388608.0; + static constexpr double Q30 = 1073741824.0; + static constexpr double Q31 = 2147483648.0; + + // Headroom rules of AirohaPeqLibrary.dll + static constexpr double EQ_STAGE_PEAK_LIMIT = 4.0; + static constexpr double EQ_OUTPUT_PEAK_LIMIT = 8.0; // 18.06 dB + static constexpr double EQ_FIRST_STAGE_MARGIN = 1.19; + + // Biquad coefficients, normalised to a0 = 1 + enum EqCoefficient : size_t { + COEF_B0, + COEF_B1, + COEF_B2, + COEF_A1, + COEF_A2 + }; + using EqFilter = std::array; + + static uint32_t readLE32(std::span bytes, size_t offset) + { + return uint32_t(bytes[offset]) | (uint32_t(bytes[offset + 1]) << 8) + | (uint32_t(bytes[offset + 2]) << 16) | (uint32_t(bytes[offset + 3]) << 24); } - Result getChatmix(hid_device* device_handle) override + static void writeLE32(std::span bytes, size_t offset, uint32_t value) { - auto status_result = getDeviceStatus(device_handle); - if (!status_result) { - return status_result.error(); + for (size_t byte = 0; byte < 4; ++byte) { + bytes[offset + byte] = static_cast(value >> (8 * byte)); } + } - int level = status_result->chatmix_level; - - // Calculate percentages (0-20 range, 10 = center) - // level is already mapped to 0-128 - int game_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); - int chat_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); + // DSP words are high halfword first, with each halfword little-endian. + static void appendDsp32(Message& bytes, int32_t value) + { + const auto bits = static_cast(value); + appendLE16(bytes, static_cast(bits >> 16)); + appendLE16(bytes, static_cast(bits)); + } - return ChatmixResult { - .level = level, - .game_volume_percent = game_pct, - .chat_volume_percent = chat_pct - }; + /// Start of a band's record in the stored settings + static constexpr size_t eqBandOffset(size_t band) + { + return EQ_HEADER_SIZE + EQ_BAND_RECORD_SIZE * band; } - Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + static double readEqBandValue(std::span settings, size_t offset) { - // Maxwell supports presets 0-9 (mapped to device presets 1-10) - // 0-5 are built-in presets, 6-9 are custom presets - if (preset >= EQUALIZER_PRESETS_COUNT) { - return DeviceError::invalidParameter("Device only supports presets 0-9"); - } + return std::bit_cast(readLE32(settings, offset)) * EQ_VALUE_SCALE; + } - // Device uses 1-10 range internally - uint8_t device_preset = preset + 1; + static bool isSupportedEqBand(const EqBand& band, int rate) + { + return std::isfinite(band.frequency) && std::isfinite(band.bandwidth) + && band.frequency > 0 && band.frequency < rate / 2 + && band.bandwidth > 0 && band.bandwidth <= EQ_MAX_BANDWIDTH + && isSupportedEqGain(band.gain); + } - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x00, 0x00, device_preset }; + static EqFilter peakingFilter(const EqBand& band, double rate) + { + const double omega = band.frequency * (2 * std::numbers::pi) / rate; + const double amplitude = std::sqrt(std::pow(10.0, band.gain / 20.0)); + const double alpha = std::sin(omega) / (2 * (band.frequency / band.bandwidth)); + const double a0 = 1 + alpha / amplitude; + const double b1 = -2 * std::cos(omega) / a0; + return { (1 + alpha * amplitude) / a0, b1, (1 - alpha * amplitude) / a0, + b1, (1 - alpha / amplitude) / a0 }; + } - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } + static std::complex filterResponse(const EqFilter& filter, std::complex z) + { + return (filter[COEF_B0] + filter[COEF_B1] * z + filter[COEF_B2] * z * z) + / (1.0 + filter[COEF_A1] * z + filter[COEF_A2] * z * z); + } - return EqualizerPresetResult { .preset = preset, .total_presets = EQUALIZER_PRESETS_COUNT }; + static bool isStableFilter(const EqFilter& filter) + { + // Jury stability conditions for z^2 + a1*z + a2. + return std::abs(filter[COEF_A2]) < 1 && 1 + filter[COEF_A1] + filter[COEF_A2] > 0 + && 1 - filter[COEF_A1] + filter[COEF_A2] > 0; } - Result setNoiseFilter(hid_device* device_handle, uint8_t level) override + /// b1 and a1 are stored with one fractional bit less than the other coefficients + static constexpr bool hasHalfResolution(size_t coefficient) { - // Maxwell 2 has three levels for the noise filter: high, low, and - // off (2,1 and 0) - if (level > 2) { - return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); - } - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x24, 0x00, level }; + return coefficient == COEF_B1 || coefficient == COEF_A1; + } - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + /** + * @brief Peak gain of each prefix of a filter cascade + * + * Element i is the peak of filters 0..i in series, on the DLL's 1 Hz grid (DC inclusive, + * Nyquist exclusive). It reproduces the per-stage headroom decisions without global state. + */ + static std::vector cascadePeaks(std::span filters, int rate) + { + std::vector peaks(filters.size(), 0.0); + for (int frequency = 0; frequency < rate / 2; ++frequency) { + const auto z = std::polar(1.0, -2 * std::numbers::pi * frequency / rate); + std::complex cascade { 1, 0 }; + for (size_t i = 0; i < filters.size(); ++i) { + cascade *= filterResponse(filters[i], z); + peaks[i] = std::max(peaks[i], std::abs(cascade)); + } } - - return NoiseFilterResult { .level = level }; + return peaks; } }; } // namespace headsetcontrol diff --git a/lib/devices/audeze_maxwell2.hpp b/lib/devices/audeze_maxwell2.hpp index 0a197f34..fee80443 100644 --- a/lib/devices/audeze_maxwell2.hpp +++ b/lib/devices/audeze_maxwell2.hpp @@ -1,12 +1,8 @@ #pragma once -#include "../result_types.hpp" -#include "hid_device.hpp" +#include "audeze_maxwell_device.hpp" #include -#include -#include #include -#include using namespace std::string_view_literals; @@ -16,7 +12,7 @@ namespace headsetcontrol { * @brief Audeze Maxwell 2 Gaming Headset * * Features: - * - Sidetone (32 levels) + * - Sidetone (level read from the headset, 32 levels by default) * - Inactive time with discrete levels * - Equalizer presets (10 total: 6 default + 4 custom) * - Battery status @@ -24,51 +20,15 @@ namespace headsetcontrol { * - Voice prompts * - Mic noise filter * - * Special Requirements: - * - 14-packet initialization sequence followed by 6 status requests - * - 60ms delay between packets (official software sends at 52ms but 60ms is more reliable in testing) - * - Uses hid_get_input_report for responses + * The protocol is implemented in audeze_maxwell_device.hpp. */ -class AudezeMaxwell2 : public HIDDevice { +class AudezeMaxwell2 : public AudezeMaxwellDevice { public: static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x4b29, // Maxwell 2 (PlayStation/PC version) 0x4b28 // Maxwell 2 (Xbox version) }; - static constexpr int MSG_SIZE = 62; - static constexpr int REPORT_ID = 0x06; - static constexpr int DELAY_US = 60000; // 60ms - - // 14-packet initialization sequence - static constexpr std::array, 14> UNIQUE_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x20 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x25 }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x28 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x01 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 }, - { 0x06, 0x07, 0x00, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2D }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2F }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0xD6, 0x0C } } }; - - // 6 packet status request sequence - static constexpr std::array, 6> STATUS_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x22 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 } } }; - - constexpr uint16_t getVendorId() const override - { - return 0x3329; // Audeze - } - std::vector getProductIds() const override { return { SUPPORTED_PRODUCT_IDS.begin(), SUPPORTED_PRODUCT_IDS.end() }; @@ -83,362 +43,12 @@ class AudezeMaxwell2 : public HIDDevice { { return B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_EQUALIZER_PRESET) | B(CAP_BATTERY_STATUS) | B(CAP_CHATMIX_STATUS) | B(CAP_VOICE_PROMPTS) - | B(CAP_NOISE_FILTER); + | B(CAP_NOISE_FILTER) | B(CAP_SIDETONE_STATUS); } constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override { return { .usagepage = 0xff13, .usageid = 0x1, .interface_id = 5 }; } - - static constexpr int EQUALIZER_PRESETS_COUNT = 10; - - uint8_t getEqualizerPresetsCount() const override - { - return EQUALIZER_PRESETS_COUNT; - } - -private: - /** - * @brief Get device status (battery, chatmix, sidetone, mic mute) - * - * Must send all 6 status requests to get consistent information - */ - struct MaxwellStatus { - BatteryResult battery; - int chatmix_level; - int sidetone_level; - int eq_setting; - int ainf_level; - bool sidetone_enabled; - bool mic_muted; - }; - - /** - * @brief Send request and get input report with 60ms delay to match Audeze HQ. - */ - Result sendGetInputReport(hid_device* device_handle, std::span data, std::span buff = {}) const - { - std::this_thread::sleep_for(std::chrono::microseconds(DELAY_US)); - - if (auto result = writeHID(device_handle, data, MSG_SIZE); !result) { - return result.error(); - } - - if (buff.empty()) { - std::array tempBuff { 0x7 }; - auto read_result = getInputReport(device_handle, tempBuff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } - } else { - buff[0] = 0x7; - auto read_result = getInputReport(device_handle, buff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } - } - - return {}; - } - - /** - * @brief Get comprehensive device status (battery, chatmix, sidetone, etc.) - * - * Must send all initialization and status requests packets to get consistent information - */ - Result getDeviceStatus(hid_device* device_handle) const - { - std::array, 6> status_buffs {}; - - // Send UNIQUE_REQUESTS packets - for (const auto& unique_request : UNIQUE_REQUESTS) { - auto result = sendGetInputReport(device_handle, unique_request); - if (!result) { - return result.error(); - } - } - - // Send STATUS_REQUESTS packets - for (int i = 0; i < 6; ++i) { - auto result = sendGetInputReport(device_handle, STATUS_REQUESTS[i], status_buffs[i]); - if (!result) { - return result.error(); - } - } - - // Status_buffs structure - // status_buffs[0] = battery_status - // status_buffs[1] = mic_status - // status_buffs[2] = eq - // status_buffs[3] = chatmix - // status_buffs[4] = ainf_level - // status_buffs[5] = sidetone_level - - MaxwellStatus status {}; - - const auto& buff = status_buffs[0]; - - // Initialize battery status as unavailable - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - - // Parse battery level - if (buff[0] == 0x00) { - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - } else { - // Find battery level in response (level is first byte after d6 0c 00 00 in packet) - for (int i = 0; i < MSG_SIZE - 4; ++i) { - if (buff[i] == 0xD6 && buff[i + 1] == 0x0C && buff[i + 2] == 0x00 && buff[i + 3] == 0x00) { - status.battery.level_percent = buff[i + 4]; - status.battery.status = BATTERY_AVAILABLE; - break; - } - } - } - - // Parse microphone mute status (FF = unmuted) - status.mic_muted = status_buffs[1][12] != 0xFF; - - // Parse current eq setting - // 1 = Audeze, 2 = Treble Boost, 3 = Bass Boost, 4 = Immersive, 5 = Competition, - // 6 = Footsteps, 7 = EQ1, 8 = EQ2, 9 = EQ3, 10 = EQ4 - status.eq_setting = status_buffs[2][12]; - - // Parse chatmix (0-20 range, center at 10) - status.chatmix_level = map(status_buffs[3][12], 0, 20, 0, 128); - - // Parse current mic noise filter level - // 0 = Off, 1 = Low, 2 = High - status.ainf_level = status_buffs[4][12]; - - // Parse sidetone (0-31 range) - status.sidetone_level = map(status_buffs[5][12], 0, 31, 0, 128); - status.sidetone_enabled = status_buffs[5][12] != 0; - - return status; - } - - static constexpr auto STATUS_REUSE_WINDOW = std::chrono::milliseconds(500); - - /** - * @brief Reuse one status read for every info capability. - * - * getDeviceStatus() costs 21 packets at 60 ms and already returns everything, - * but each getter called it again. Keyed on the handle since one instance - * serves every attached Maxwell 2. - * - * The reused status also carries sidetone, equalizer and noise filter, so - * every setter that writes one of those calls invalidateStatus(). A getter - * built on top of this must keep that list in sync, otherwise a set followed - * by a get inside the window returns the value from before the write. - */ - Result statusFor(hid_device* device_handle) - { - if (last_status_ && last_handle_ == device_handle - && std::chrono::steady_clock::now() - read_at_ < STATUS_REUSE_WINDOW) { - return *last_status_; - } - - auto status = getDeviceStatus(device_handle); - if (!status) { - return status.error(); - } - - last_handle_ = device_handle; - last_status_ = *status; - // After the read, not before: it takes ~1.4 s, so a timestamp taken going - // in would already have expired. - read_at_ = std::chrono::steady_clock::now(); - - return *status; - } - - // Drop the reused status after a write that changes one of its fields. - void invalidateStatus() - { - last_status_.reset(); - } - - hid_device* last_handle_ = nullptr; - std::optional last_status_; - std::chrono::steady_clock::time_point read_at_ {}; - -public: - // Rich Results V2 API - Result getBattery(hid_device* device_handle) override - { - auto status_result = statusFor(device_handle); - if (!status_result) { - return status_result.error(); - } - - return status_result->battery; - } - - Result setSidetone(hid_device* device_handle, uint8_t level) override - { - invalidateStatus(); - - // Maxwell range: 0 to 31 - uint8_t mapped = map(level, 0, 128, 0, 31); - - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, mapped }; - - // Sidetone is enabled whenever level changes - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - if (mapped == 0) { - // Toggle sidetone off - std::array toggle_cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x82, 0x2C, 0x07, 0x00, 0x00 }; - result = sendGetInputReport(device_handle, toggle_cmd); - if (!result) { - return result.error(); - } - } - - return SidetoneResult { - .current_level = level, - .min_level = 0, - .max_level = 128, - .device_min = 0, - .device_max = 31 - }; - } - - Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override - { - std::array cmd { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00 }; - - /* The headset supports auto shutdown times up to 6 hours or 360 minutes but since this uses an 8-bit - * integer it is limited to 255. Here it is truncated to 240 minutes (4 hours) which is the closest - * option from the Audeze software. - */ - - if (minutes > 0) { - // Round to supported values - uint16_t final_minutes = 240; - if (minutes <= 5) - final_minutes = 5; - else if (minutes <= 10) - final_minutes = 10; - else if (minutes <= 15) - final_minutes = 15; - else if (minutes <= 30) - final_minutes = 30; - else if (minutes <= 45) - final_minutes = 45; - else if (minutes <= 60) - final_minutes = 60; - else if (minutes <= 90) - final_minutes = 90; - else if (minutes <= 120) - final_minutes = 120; - else if (minutes <= 240) - final_minutes = 240; - - uint16_t seconds = final_minutes * 60; - - cmd[11] = 0x01; // Inactive time flag - cmd[13] = seconds & 0xFF; // LSB - cmd[14] = (seconds >> 8) & 0xFF; // MSB - cmd[15] = 0x01; // Constant flag - cmd[17] = cmd[13]; // LSB again - cmd[18] = cmd[14]; // MSB again - } - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return InactiveTimeResult { - .minutes = minutes, - .min_minutes = 0, - .max_minutes = 240 - }; - } - - Result setVoicePrompts(hid_device* device_handle, bool enabled) override - { - // Voice prompt level: 0 (0%) to 15 (100%), default 7 (50%) - // API doesn't support level, so use 7 when on, 0 when off - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x20, 0x00, enabled ? uint8_t(0x07) : uint8_t(0x00) }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return VoicePromptsResult { .enabled = enabled }; - } - - Result getChatmix(hid_device* device_handle) override - { - auto status_result = statusFor(device_handle); - if (!status_result) { - return status_result.error(); - } - - int level = status_result->chatmix_level; - - // Calculate percentages (0-20 range, 10 = center) - // level is already mapped to 0-128 - int game_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); - int chat_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); - - return ChatmixResult { - .level = level, - .game_volume_percent = game_pct, - .chat_volume_percent = chat_pct - }; - } - - Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override - { - // Maxwell supports presets 0-9 (mapped to device presets 1-10) - // 0-5 are built-in presets, 6-9 are custom presets - if (preset >= EQUALIZER_PRESETS_COUNT) { - return DeviceError::invalidParameter("Device only supports presets 0-9"); - } - - invalidateStatus(); - - // Device uses 1-10 range internally - uint8_t device_preset = preset + 1; - - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x00, 0x00, device_preset }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return EqualizerPresetResult { .preset = preset, .total_presets = EQUALIZER_PRESETS_COUNT }; - } - - Result setNoiseFilter(hid_device* device_handle, uint8_t level) override - { - // Maxwell 2 has three levels for the noise filter: high, low, and - // off (2,1 and 0) - if (level > 2) { - return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); - } - - invalidateStatus(); - - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x24, 0x00, level }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return NoiseFilterResult { .level = level }; - } }; } // namespace headsetcontrol diff --git a/lib/devices/audeze_maxwell_device.hpp b/lib/devices/audeze_maxwell_device.hpp new file mode 100644 index 00000000..864491fd --- /dev/null +++ b/lib/devices/audeze_maxwell_device.hpp @@ -0,0 +1,651 @@ +#pragma once + +#include "../result_types.hpp" +#include "device_utils.hpp" +#include "hid_device.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace headsetcontrol { + +/** + * @brief Base class for Audeze Maxwell devices (Maxwell and Maxwell 2) + * + * Implements the Airoha based control protocol both models share. + * + * Recovered from Audeze.exe and a USB capture of the original Maxwell dongle. + * + * HID reports are 62 bytes: output report 0x06 via hid_write, input report 0x07 via + * hid_get_input_report. + * + * + * + * len : number of valid fragment bytes, 0..59. Everything after them is padding or + * leftovers of older reports and must be ignored. A report with len 0 means + * "nothing new yet", whatever its remaining bytes look like. + * route : 0x80 to reach the headset through the dongle + * + * The fragments form a stream of messages. One report can carry several messages and a + * message can span several reports: + * + * 05 + * + * type : 5A request, 5B response, 5C notification, 5D asynchronous result + * len : body length; the first two body bytes identify the command + * + * Commands used, as request body -> reply body. Fixed bytes are written in hex, the + * bytes that vary are named: + * + * : which setting, in the 09 family (the KEY_ constants) + * : which setting, in the 2C family (the PARAM_ constants) + * : the setting itself; one byte unless noted + * : 00 on success + * + * read key 01 09 00 -> 5B: 01 09 00 + * write key 00 09 00 -> 5B: 00 09 00 00 + * read parameter 83 2C 00 -> 5B: 83 2C 00 + * write parameter 82 2C 00 -> 5B: 82 2C 00 + * battery D6 0C 00 -> 5B: D6 0C + * 5D: D6 0C 00 00 + * + * A parameter value can be several bytes long (auto power off). Writing the sidetone + * parameter toggles sidetone whatever value is sent. + * + * Replies carry no sequence number, so they are matched on the command identity and + * anything else that is queued is skipped. + */ +class AudezeMaxwellDevice : public HIDDevice { +public: + static constexpr size_t MSG_SIZE = 62; + static constexpr uint8_t REPORT_ID_OUT = 0x06; + static constexpr uint8_t REPORT_ID_IN = 0x07; + static constexpr size_t FRAGMENT_OFFSET = 3; + static constexpr size_t MAX_FRAGMENT_SIZE = MSG_SIZE - FRAGMENT_OFFSET; + static constexpr uint8_t ROUTE_HEADSET = 0x80; + + static constexpr uint8_t MESSAGE_MARKER = 0x05; + static constexpr uint8_t TYPE_REQUEST = 0x5A; + static constexpr uint8_t TYPE_RESPONSE = 0x5B; + static constexpr uint8_t TYPE_RESULT = 0x5D; + static constexpr size_t TYPE_OFFSET = 1; + static constexpr size_t LENGTH_OFFSET = 2; + static constexpr size_t HEADER_SIZE = 4; + static constexpr size_t MAX_MESSAGE_SIZE = 1024; + + static constexpr uint8_t STATUS_OK = 0x00; + + using Message = std::vector; + using Report = std::array; + + /// The first two body bytes, which identify a command + using Command = std::array; + + static constexpr Command CMD_WRITE_KEY { 0x00, 0x09 }; + static constexpr Command CMD_READ_KEY { 0x01, 0x09 }; + static constexpr Command CMD_WRITE_PARAMETER { 0x82, 0x2C }; + static constexpr Command CMD_READ_PARAMETER { 0x83, 0x2C }; + static constexpr Command CMD_BATTERY { 0xD6, 0x0C }; + + // Keys of the 01 09 (read) / 00 09 (write) family + static constexpr uint8_t KEY_EQ_PRESET = 0x00; + static constexpr uint8_t KEY_VOICE_PROMPT_LEVEL = 0x20; + static constexpr uint8_t KEY_MIC_MUTE = 0x22; + static constexpr uint8_t KEY_NOISE_FILTER = 0x24; + static constexpr uint8_t KEY_VOLUME_LIMITER = 0x28; + static constexpr uint8_t KEY_SIDETONE_LEVEL = 0x2C; + static constexpr uint8_t KEY_SIDETONE_MAX_LEVEL = 0x2D; + + // Parameters of the 83 2C (read) / 82 2C (write) family + static constexpr uint8_t PARAM_AUTO_POWER_OFF = 0x01; + static constexpr uint8_t PARAM_SIDETONE_ENABLED = 0x07; + static constexpr uint8_t PARAM_CHATMIX = 0x0B; + + // KEY_MIC_MUTE reads 00 while muted and FF otherwise + static constexpr uint8_t MIC_MUTED = 0x00; + + // Voice prompt level: 0 (0%) to 15 (100%), default 7 (50%) + static constexpr uint8_t VOICE_PROMPT_LEVEL_OFF = 0x00; + static constexpr uint8_t VOICE_PROMPT_LEVEL_DEFAULT = 0x07; + + static constexpr uint8_t SIDETONE_API_MAX = 128; + static constexpr uint8_t DEFAULT_SIDETONE_MAX = 31; + static constexpr uint8_t CHATMIX_MAX = 20; + static constexpr uint8_t NOISE_FILTER_MAX = 2; + + static constexpr int EQUALIZER_PRESETS_COUNT = 10; + + constexpr uint16_t getVendorId() const override + { + return VENDOR_AUDEZE; + } + + uint8_t getEqualizerPresetsCount() const override + { + return EQUALIZER_PRESETS_COUNT; + } + + /// Append a 16-bit value, low byte first + static void appendLE16(Message& bytes, uint16_t value) + { + bytes.push_back(static_cast(value & 0xFF)); + bytes.push_back(static_cast(value >> 8)); + } + + /// The 16-bit value stored low byte first at @p offset + static uint16_t readLE16(std::span bytes, size_t offset) + { + return static_cast(bytes[offset] | (bytes[offset + 1] << 8)); + } + + /// Build a message: 05 + static Message buildMessage(uint8_t type, std::span body) + { + Message message { MESSAGE_MARKER, type }; + appendLE16(message, static_cast(body.size())); + message.insert(message.end(), body.begin(), body.end()); + return message; + } + + /// Body of a request: the command followed by its arguments + static Message commandBody(Command command, std::initializer_list arguments = {}) + { + Message body(command.begin(), command.end()); + body.insert(body.end(), arguments.begin(), arguments.end()); + return body; + } + + /// Split a message into zero padded output reports + static std::vector buildReports(std::span message) + { + std::vector reports; + for (size_t offset = 0; offset < message.size(); offset += MAX_FRAGMENT_SIZE) { + const size_t length = std::min(MAX_FRAGMENT_SIZE, message.size() - offset); + + Report report {}; + report[0] = REPORT_ID_OUT; + report[1] = static_cast(length); + report[2] = ROUTE_HEADSET; + std::copy_n(message.begin() + offset, length, report.begin() + FRAGMENT_OFFSET); + reports.push_back(report); + } + return reports; + } + + /// The valid fragment of an input report; empty if it carries nothing new + static std::span reportFragment(std::span report) + { + if (report.size() < FRAGMENT_OFFSET) { + return {}; + } + const size_t length = report[1]; + if (length > MAX_FRAGMENT_SIZE || FRAGMENT_OFFSET + length > report.size()) { + return {}; + } + return report.subspan(FRAGMENT_OFFSET, length); + } + + /** + * @brief Take the next complete message off the front of a fragment stream + * + * Bytes that cannot start a message are dropped. Returns an empty message if the + * stream holds no complete message yet. + */ + static Message extractMessage(std::vector& stream) + { + while (!stream.empty()) { + if (stream[0] != MESSAGE_MARKER) { + stream.erase(stream.begin()); + continue; + } + if (stream.size() < HEADER_SIZE) { + break; + } + const size_t size = HEADER_SIZE + readLE16(stream, LENGTH_OFFSET); + if (size > MAX_MESSAGE_SIZE) { + stream.erase(stream.begin()); + continue; + } + if (stream.size() < size) { + break; + } + Message message(stream.begin(), stream.begin() + size); + stream.erase(stream.begin(), stream.begin() + size); + return message; + } + return {}; + } + +protected: + // Non-static so tests can shorten them + std::chrono::milliseconds response_timeout_ { 400 }; + std::chrono::milliseconds poll_interval_ { 20 }; + + static constexpr int MAX_ATTEMPTS = 3; + + /// How a request is sent and how long its reply is awaited + struct RequestOptions { + /// How often the request is sent while it times out; 1 if it is not safe to repeat + int attempts = MAX_ATTEMPTS; + /// How long to wait for the reply; response_timeout_ if not set + std::optional timeout {}; + /// Pause between the reports of a request that does not fit into one + std::chrono::milliseconds fragment_interval { 0 }; + }; + + /// True if a message is of the given type and belongs to the given command + static bool isMessage(const Message& message, uint8_t type, Command command) + { + return message.size() >= HEADER_SIZE + command.size() + && message[TYPE_OFFSET] == type + && std::equal(command.begin(), command.end(), message.begin() + HEADER_SIZE); + } + + /// True if a message carries the 16-bit @p id (a key or a parameter) at @p offset + static bool hasId(const Message& message, size_t offset, uint16_t id) + { + return message.size() >= offset + 2 && readLE16(message, offset) == id; + } + + /** + * @brief Wait for the message accepted by @p accept, skipping everything else + * + * The headset answers after roughly 60-120 ms, and until then the dongle returns + * reports with length 0, so the input report is polled until the timeout. + */ + template + Result awaitMessage(hid_device* device_handle, Predicate accept, std::chrono::milliseconds timeout) const + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + std::vector stream; + + while (true) { + for (auto message = extractMessage(stream); !message.empty(); message = extractMessage(stream)) { + if (accept(message)) { + return message; + } + } + + if (std::chrono::steady_clock::now() >= deadline) { + return DeviceError::timeout("No matching reply from the Maxwell"); + } + std::this_thread::sleep_for(poll_interval_); + + Report report { REPORT_ID_IN }; + auto read_result = getInputReport(device_handle, report); + if (!read_result) { + return read_result.error(); + } + + const auto fragment = reportFragment(std::span(report).first(std::min(*read_result, report.size()))); + stream.insert(stream.end(), fragment.begin(), fragment.end()); + } + } + + /// Write the reports of one request, pausing for @p interval between them + Result sendReports(hid_device* device_handle, std::span reports, std::chrono::milliseconds interval) const + { + for (size_t i = 0; i < reports.size(); ++i) { + if (i > 0 && interval.count() > 0) { + std::this_thread::sleep_for(interval); + } + if (auto result = writeHID(device_handle, reports[i], MSG_SIZE); !result) { + return result.error(); + } + } + return {}; + } + + /** + * @brief Send a request and return the reply accepted by @p accept + * + * Reading an input report consumes it, so another program polling the same dongle + * (the Audeze app does so about once a second) can take a reply meant for us, while + * we see its replies instead. The request is therefore repeated on a timeout, unless + * @p options says otherwise. + */ + template + Result request(hid_device* device_handle, const Message& body, Predicate accept, const RequestOptions& options) const + { + const auto reports = buildReports(buildMessage(TYPE_REQUEST, body)); + const auto timeout = options.timeout.value_or(response_timeout_); + + Result reply = DeviceError::timeout("No matching reply from the Maxwell"); + for (int attempt = 0; attempt < options.attempts; ++attempt) { + if (auto result = sendReports(device_handle, reports, options.fragment_interval); !result) { + return result.error(); + } + + reply = awaitMessage(device_handle, accept, timeout); + if (reply || reply.error().code != DeviceError::Code::Timeout) { + break; + } + } + return reply; + } + + template + Result request(hid_device* device_handle, const Message& body, Predicate accept) const + { + return request(device_handle, body, accept, RequestOptions {}); + } + + Result readKey(hid_device* device_handle, uint8_t key) const + { + // Reply: 05 5B 06 00 01 09 00 + static constexpr size_t KEY_OFFSET = 6; + static constexpr size_t STATUS_OFFSET = 8; + static constexpr size_t VALUE_OFFSET = 9; + + auto reply = request(device_handle, commandBody(CMD_READ_KEY, { key, 0x00 }), [key](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_READ_KEY) && hasId(message, KEY_OFFSET, key); + }); + if (!reply) { + return reply.error(); + } + if (reply->size() <= VALUE_OFFSET || (*reply)[STATUS_OFFSET] != STATUS_OK) { + return DeviceError::protocolError(std::format("Maxwell rejected reading key 0x{:02x}", key)); + } + return (*reply)[VALUE_OFFSET]; + } + + Result writeKey(hid_device* device_handle, uint8_t key, uint8_t value) const + { + // Reply: 05 5B 05 00 00 09 00 00 + static constexpr size_t FAMILY_OFFSET = 5; + static constexpr size_t KEY_OFFSET = 6; + + // Audeze.exe only checks the family byte and the key of this acknowledgement + auto reply = request(device_handle, commandBody(CMD_WRITE_KEY, { key, 0x00, value }), [key](const Message& message) { + return message.size() > KEY_OFFSET && message[TYPE_OFFSET] == TYPE_RESPONSE + && message[FAMILY_OFFSET] == CMD_WRITE_KEY[1] && message[KEY_OFFSET] == key; + }); + if (!reply) { + return reply.error(); + } + return {}; + } + + Result readParameter(hid_device* device_handle, uint8_t parameter) const + { + // Reply: 05 5B 06 00 83 2C 00 + static constexpr size_t STATUS_OFFSET = 6; + static constexpr size_t PARAMETER_OFFSET = 7; + static constexpr size_t VALUE_OFFSET = 9; + + auto reply = request(device_handle, commandBody(CMD_READ_PARAMETER, { parameter, 0x00 }), [parameter](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_READ_PARAMETER) && hasId(message, PARAMETER_OFFSET, parameter); + }); + if (!reply) { + return reply.error(); + } + if (reply->size() <= VALUE_OFFSET || (*reply)[STATUS_OFFSET] != STATUS_OK) { + return DeviceError::protocolError(std::format("Maxwell rejected reading parameter 0x{:02x}", parameter)); + } + return (*reply)[VALUE_OFFSET]; + } + + Result writeParameter(hid_device* device_handle, uint8_t parameter, std::span value, const RequestOptions& options) const + { + // Reply: 05 5B 05 00 82 2C 00 + static constexpr size_t STATUS_OFFSET = 6; + static constexpr size_t PARAMETER_OFFSET = 7; + + auto body = commandBody(CMD_WRITE_PARAMETER, { parameter, 0x00 }); + body.insert(body.end(), value.begin(), value.end()); + + auto reply = request( + device_handle, body, [parameter](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_WRITE_PARAMETER) && hasId(message, PARAMETER_OFFSET, parameter); + }, + options); + if (!reply) { + return reply.error(); + } + if ((*reply)[STATUS_OFFSET] != STATUS_OK) { + return DeviceError::protocolError(std::format("Maxwell rejected writing parameter 0x{:02x}", parameter)); + } + return {}; + } + + Result writeParameter(hid_device* device_handle, uint8_t parameter, std::span value) const + { + return writeParameter(device_handle, parameter, value, RequestOptions {}); + } + + /// Highest sidetone level the headset accepts + uint8_t readSidetoneMax(hid_device* device_handle) const + { + auto max_level = readKey(device_handle, KEY_SIDETONE_MAX_LEVEL); + if (!max_level || *max_level == 0) { + return DEFAULT_SIDETONE_MAX; + } + return *max_level; + } + + /** + * @brief Switch sidetone on or off + * + * The headset toggles sidetone on every write to this parameter, whatever value is + * sent (verified on an original Maxwell). So the state is read first, the write is + * only sent if it differs, and the result is read back. + */ + Result setSidetoneEnabled(hid_device* device_handle, bool enabled) const + { + static constexpr int MAX_TOGGLES = 2; + + for (int toggles = 0;; ++toggles) { + auto current = readParameter(device_handle, PARAM_SIDETONE_ENABLED); + if (!current) { + return current.error(); + } + const bool is_enabled = *current != 0x00; + if (is_enabled == enabled) { + return {}; + } + if (toggles == MAX_TOGGLES) { + return DeviceError::protocolError("Maxwell did not change the sidetone state"); + } + + // Sent once, as repeating it would toggle back. A lost acknowledgement does + // not matter: the state read above decides. + const std::array value { static_cast(enabled ? 0x01 : 0x00) }; + auto result = writeParameter(device_handle, PARAM_SIDETONE_ENABLED, value, { .attempts = 1 }); + if (!result && result.error().code != DeviceError::Code::Timeout) { + return result.error(); + } + } + } + +public: + Result getBattery(hid_device* device_handle) override + { + // Replies: 05 5B 03 00 D6 0C , then 05 5D 05 00 D6 0C 00 00 + static constexpr size_t STATUS_OFFSET = 6; + static constexpr size_t PERCENT_OFFSET = 8; + + BatteryResult battery { .level_percent = -1, .status = BATTERY_UNAVAILABLE }; + + // Only the result is needed; a failed acknowledgement ends the wait early + auto result = request(device_handle, commandBody(CMD_BATTERY, { 0x00 }), [](const Message& message) { + const bool has_level = isMessage(message, TYPE_RESULT, CMD_BATTERY) && message.size() > PERCENT_OFFSET; + const bool refused = isMessage(message, TYPE_RESPONSE, CMD_BATTERY) && message.size() > STATUS_OFFSET + && message[STATUS_OFFSET] != STATUS_OK; + return has_level || refused; + }); + if (!result) { + // The headset does not answer while it is switched off + if (result.error().code == DeviceError::Code::Timeout) { + return battery; + } + return result.error(); + } + if ((*result)[TYPE_OFFSET] != TYPE_RESULT) { + return battery; + } + + battery.level_percent = std::min((*result)[PERCENT_OFFSET], 100); + battery.status = BATTERY_AVAILABLE; + + if (auto mic = readKey(device_handle, KEY_MIC_MUTE); mic && *mic == MIC_MUTED) { + battery.mic_status = MICROPHONE_UP; + } + + return battery; + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + const uint8_t device_max = readSidetoneMax(device_handle); + const uint8_t mapped = map(level, 0, SIDETONE_API_MAX, 0, device_max); + const bool enabled = mapped > 0; + + // The level and the on/off state are stored separately. Writing a level also + // switches sidetone on, so level 0 only switches it off and keeps the stored level. + if (enabled) { + if (auto result = writeKey(device_handle, KEY_SIDETONE_LEVEL, mapped); !result) { + return result.error(); + } + } + + if (auto result = setSidetoneEnabled(device_handle, enabled); !result) { + return result.error(); + } + + return SidetoneResult { + .current_level = level, + .min_level = 0, + .max_level = SIDETONE_API_MAX, + .device_min = 0, + .device_max = device_max, + .is_muted = !enabled, + .device_level = mapped + }; + } + + Result getSidetone(hid_device* device_handle) override + { + auto device_level = readKey(device_handle, KEY_SIDETONE_LEVEL); + if (!device_level) { + return device_level.error(); + } + + auto enabled = readParameter(device_handle, PARAM_SIDETONE_ENABLED); + if (!enabled) { + return enabled.error(); + } + + const uint8_t device_max = std::max(readSidetoneMax(device_handle), *device_level); + const bool muted = *enabled == 0 || *device_level == 0; + + return SidetoneResult { + .current_level = muted ? uint8_t(0) : map(*device_level, 0, device_max, 0, SIDETONE_API_MAX), + .min_level = 0, + .max_level = SIDETONE_API_MAX, + .device_min = 0, + .device_max = device_max, + .is_muted = muted, + .device_level = *device_level + }; + } + + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + { + // Round up to the nearest value the headset supports. The longest one is 6 hours, + // which the 8-bit argument cannot express, so anything above 4 hours selects it. + static constexpr std::array SUPPORTED_MINUTES { 5, 10, 15, 30, 45, 60, 90, 120, 240 }; + static constexpr uint16_t LONGEST_MINUTES = 360; + + uint16_t seconds = 0; + if (minutes > 0) { + const auto supported = std::ranges::lower_bound(SUPPORTED_MINUTES, minutes); + seconds = (supported != SUPPORTED_MINUTES.end() ? *supported : LONGEST_MINUTES) * 60; + } + + // Enable flag and seconds (little endian), sent twice + const uint8_t enabled = seconds > 0 ? 0x01 : 0x00; + const auto lsb = static_cast(seconds & 0xFF); + const auto msb = static_cast(seconds >> 8); + const std::array value { enabled, 0x00, lsb, msb, enabled, 0x00, lsb, msb }; + if (auto result = writeParameter(device_handle, PARAM_AUTO_POWER_OFF, value); !result) { + return result.error(); + } + + return InactiveTimeResult { + .minutes = minutes, + .min_minutes = 0, + .max_minutes = 255 + }; + } + + Result setVoicePrompts(hid_device* device_handle, bool enabled) override + { + // The API has no level, so prompts are either off or at the default level + const uint8_t prompt_level = enabled ? VOICE_PROMPT_LEVEL_DEFAULT : VOICE_PROMPT_LEVEL_OFF; + if (auto result = writeKey(device_handle, KEY_VOICE_PROMPT_LEVEL, prompt_level); !result) { + return result.error(); + } + + return VoicePromptsResult { .enabled = enabled }; + } + + Result getChatmix(hid_device* device_handle) override + { + auto raw = readParameter(device_handle, PARAM_CHATMIX); + if (!raw) { + return raw.error(); + } + + // 0-20 range, 10 = center + const int level = map(static_cast(std::min(*raw, CHATMIX_MAX)), 0, static_cast(CHATMIX_MAX), 0, 128); + + const int game_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); + const int chat_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); + + return ChatmixResult { + .level = level, + .game_volume_percent = game_pct, + .chat_volume_percent = chat_pct + }; + } + + Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + { + // Maxwell supports presets 0-9 (mapped to device presets 1-10) + // 0-5 are built-in presets, 6-9 are custom presets + // 1 = Audeze, 2 = Treble Boost, 3 = Bass Boost, 4 = Immersive, 5 = Competition, + // 6 = Footsteps, 7 = EQ1, 8 = EQ2, 9 = EQ3, 10 = EQ4 + if (preset >= EQUALIZER_PRESETS_COUNT) { + return DeviceError::invalidParameter("Device only supports presets 0-9"); + } + + if (auto result = writeKey(device_handle, KEY_EQ_PRESET, static_cast(preset + 1)); !result) { + return result.error(); + } + + return EqualizerPresetResult { .preset = preset, .total_presets = EQUALIZER_PRESETS_COUNT }; + } + + Result setNoiseFilter(hid_device* device_handle, uint8_t level) override + { + // Three levels for the noise filter: high, low, and off (2, 1 and 0) + if (level > NOISE_FILTER_MAX) { + return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); + } + + if (auto result = writeKey(device_handle, KEY_NOISE_FILTER, level); !result) { + return result.error(); + } + + return NoiseFilterResult { .level = level }; + } +}; + +} // namespace headsetcontrol diff --git a/tests/test_protocols.cpp b/tests/test_protocols.cpp index cc722739..5c077fb9 100644 --- a/tests/test_protocols.cpp +++ b/tests/test_protocols.cpp @@ -10,6 +10,8 @@ */ #include "device.hpp" +#include "devices/audeze_maxwell.hpp" +#include "devices/audeze_maxwell2.hpp" #include "devices/corsair_device.hpp" #include "devices/logitech_astro_a50_gen4.hpp" #include "devices/logitech_gpro_x2_lightspeed.hpp" @@ -22,6 +24,7 @@ #include "utility.hpp" #include +#include #include #include #include @@ -1332,6 +1335,556 @@ void testAstroA50Gen4TimeoutRecovery() // Test Runner // ============================================================================ +// ============================================================================ +// Audeze Maxwell Tests +// ============================================================================ + +/** + * @brief Serves queued input reports; once the queue is empty the dongle has nothing + * new and answers with a zero-length report. + */ +class MaxwellScriptedHID : public ScriptedHIDInterface { +public: + /// Number of requests that get no reply before the queue is served + size_t silent_writes = 0; + + [[nodiscard]] auto getInputReport(hid_device* /*device_handle*/, std::span data) + -> Result override + { + std::vector report { 0x07, 0x00, 0x80 }; + + // An empty queued reply leaves the current request unanswered + if (!replies.empty() && replies.front() && replies.front()->empty()) { + if (unanswered_write_ == 0) { + unanswered_write_ = writes.size(); + } + if (writes.size() > unanswered_write_) { + replies.pop_front(); + unanswered_write_ = 0; + } + } + + if (!replies.empty() && unanswered_write_ == 0 && writes.size() >= silent_writes + 1) { + auto reply = std::move(replies.front()); + replies.pop_front(); + if (!reply) { + return reply.error(); + } + report = std::move(*reply); + } + report.resize(data.size(), 0); + std::copy(report.begin(), report.end(), data.begin()); + return data.size(); + } + +private: + size_t unanswered_write_ = 0; +}; + +class TestableAudezeMaxwell : public AudezeMaxwell { +public: + mutable MaxwellScriptedHID hid; + + TestableAudezeMaxwell() + { + response_timeout_ = std::chrono::milliseconds(20); + poll_interval_ = std::chrono::milliseconds(0); + eq_timeout_ = std::chrono::milliseconds(20); + eq_fragment_interval_ = std::chrono::milliseconds(0); + } + + [[nodiscard]] auto getHIDInterface() const -> HIDInterface& override { return hid; } +}; + +/// Build an input report: 07 LEN 80 FRAGMENT..., followed by leftover bytes as on the real dongle +static std::vector maxwellReport(std::initializer_list fragment) +{ + std::vector report { 0x07, static_cast(fragment.size()), 0x80 }; + report.insert(report.end(), fragment.begin(), fragment.end()); + // Stale battery result, as seen after the valid bytes in the capture + for (uint8_t stale : { 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E }) { + report.push_back(stale); + } + report.resize(AudezeMaxwell::MSG_SIZE, 0); + return report; +} + +static std::vector maxwellWritePrefix(const std::vector& write, size_t count) +{ + return { write.begin(), write.begin() + std::min(count, write.size()) }; +} + +void testAudezeMaxwellFraming() +{ + std::cout << " Testing Audeze Maxwell framing..." << std::endl; + + std::array body { 0x01, 0x09, 0x22, 0x00 }; + auto message = AudezeMaxwell::buildMessage(AudezeMaxwell::TYPE_REQUEST, body); + auto reports = AudezeMaxwell::buildReports(message); + ASSERT_EQ(1, static_cast(reports.size()), "Short message should fit one report"); + + std::vector expected { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x22, 0x00 }; + expected.resize(AudezeMaxwell::MSG_SIZE, 0); + ASSERT_TRUE(std::equal(expected.begin(), expected.end(), reports[0].begin()), "Mic mute read should match the captured report"); + + // A long message is split into 59-byte fragments + std::vector long_body(100, 0xAA); + auto long_reports = AudezeMaxwell::buildReports(AudezeMaxwell::buildMessage(AudezeMaxwell::TYPE_REQUEST, long_body)); + ASSERT_EQ(2, static_cast(long_reports.size()), "104 bytes should need two reports"); + ASSERT_EQ(59, static_cast(long_reports[0][1]), "First fragment should be full"); + ASSERT_EQ(45, static_cast(long_reports[1][1]), "Second fragment should hold the rest"); + ASSERT_EQ(100, static_cast(long_reports[0][5]), "Body length should be little endian"); + + // A zero-length report carries nothing, whatever follows the header + auto stale = maxwellReport({}); + ASSERT_TRUE(AudezeMaxwell::reportFragment(stale).empty(), "Zero-length report should yield no bytes"); + + // Two messages in one fragment, then garbage and an incomplete message + std::vector stream { 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00, 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E, 0xFF, 0x05, 0x5B, 0x06 }; + ASSERT_EQ(7, static_cast(AudezeMaxwell::extractMessage(stream).size()), "Acknowledgement should be 7 bytes"); + ASSERT_EQ(9, static_cast(AudezeMaxwell::extractMessage(stream).size()), "Result should be 9 bytes"); + ASSERT_TRUE(AudezeMaxwell::extractMessage(stream).empty(), "Incomplete message should not be returned"); + ASSERT_EQ(3, static_cast(stream.size()), "Garbage should be dropped, the partial message kept"); + + std::cout << " [OK] Audeze Maxwell framing verified" << std::endl; +} + +void testAudezeMaxwellBattery() +{ + std::cout << " Testing Audeze Maxwell battery..." << std::endl; + + TestableAudezeMaxwell device; + // Capture frames 117-122: an empty report with stale bytes, then acknowledgement and result together + device.hid.replies.emplace_back(maxwellReport({})); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00, 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0x00 })); + + auto battery = device.getBattery(nullptr); + ASSERT_TRUE(battery.hasValue(), "Battery should be read"); + ASSERT_EQ(62, battery->level_percent, "Battery should be 62%"); + ASSERT_EQ(static_cast(BATTERY_AVAILABLE), static_cast(battery->status), "Battery should be available"); + ASSERT_EQ(static_cast(MICROPHONE_UP), static_cast(battery->mic_status), "Muted microphone should be reported"); + + ASSERT_EQ(2, static_cast(device.hid.writes.size()), "Battery and mic mute requests only"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[0], 10) == std::vector { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0xD6, 0x0C, 0x00 }), "Battery request should match the capture"); + + // Stale battery bytes behind a zero length must never be taken as a reading + TestableAudezeMaxwell offline; + auto unavailable = offline.getBattery(nullptr); + ASSERT_TRUE(unavailable.hasValue(), "A silent headset is not an error"); + ASSERT_EQ(static_cast(BATTERY_UNAVAILABLE), static_cast(unavailable->status), "Battery should be unavailable"); + ASSERT_EQ(-1, unavailable->level_percent, "No level without a reply"); + + // Unmuted microphone, acknowledgement and result in separate reports + TestableAudezeMaxwell unmuted; + unmuted.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00 })); + unmuted.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x64 })); + unmuted.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0xFF })); + auto full = unmuted.getBattery(nullptr); + ASSERT_TRUE(full.hasValue(), "Battery should be read"); + ASSERT_EQ(100, full->level_percent, "Battery should be 100%"); + ASSERT_EQ(static_cast(MICROPHONE_UNKNOWN), static_cast(full->mic_status), "Unmuted microphone is not reported as up"); + + // Another program took the acknowledgement: the result alone is enough + TestableAudezeMaxwell shared; + shared.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3D, 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x24, 0x00, 0x00, 0x00 })); + shared.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0xFF })); + auto without_ack = shared.getBattery(nullptr); + ASSERT_TRUE(without_ack.hasValue(), "Battery should be read"); + ASSERT_EQ(61, without_ack->level_percent, "Battery should be 61%"); + + // Another program took the whole reply: the request is repeated + TestableAudezeMaxwell retried; + retried.hid.silent_writes = 2; + retried.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00, 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E })); + retried.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0xFF })); + auto after_retry = retried.getBattery(nullptr); + ASSERT_TRUE(after_retry.hasValue(), "Battery should be read"); + ASSERT_EQ(62, after_retry->level_percent, "Battery should be read on the third attempt"); + ASSERT_EQ(4, static_cast(retried.hid.writes.size()), "Three battery requests and the mic mute request"); + + // A failed acknowledgement means no reading + TestableAudezeMaxwell refused; + refused.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x01 })); + auto no_reading = refused.getBattery(nullptr); + ASSERT_TRUE(no_reading.hasValue(), "A refused battery request is not an error"); + ASSERT_EQ(static_cast(BATTERY_UNAVAILABLE), static_cast(no_reading->status), "Battery should be unavailable"); + ASSERT_EQ(1, static_cast(refused.hid.writes.size()), "A refused request is not repeated"); + + std::cout << " [OK] Audeze Maxwell battery verified" << std::endl; +} + +void testAudezeMaxwellReplyMatching() +{ + std::cout << " Testing Audeze Maxwell reply matching..." << std::endl; + + // Replies to other commands and a notification are queued ahead of the chatmix reply, + // which itself is split over two reports + TestableAudezeMaxwell device; + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x24, 0x00, 0x00, 0x02, 0x05, 0x5C, 0x03, 0x00, 0x80, 0x2C, 0x03 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x01 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C })); + device.hid.replies.emplace_back(maxwellReport({})); + device.hid.replies.emplace_back(maxwellReport({ 0x00, 0x0B, 0x00, 0x0A })); + + auto chatmix = device.getChatmix(nullptr); + ASSERT_TRUE(chatmix.hasValue(), "Chatmix should be read"); + ASSERT_EQ(64, chatmix->level, "Raw 10 of 20 is the center"); + ASSERT_EQ(100, chatmix->game_volume_percent, "Game volume at center"); + ASSERT_EQ(100, chatmix->chat_volume_percent, "Chat volume at center"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[0], 11) == std::vector { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B, 0x00 }), "Chatmix request should match the capture"); + + // No matching reply is a timeout, not a value read from another command's reply + TestableAudezeMaxwell silent; + silent.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x24, 0x00, 0x00, 0x02 })); + auto missing = silent.getChatmix(nullptr); + ASSERT_TRUE(missing.hasError(), "Chatmix without a reply should fail"); + ASSERT_TRUE(missing.error().code == DeviceError::Code::Timeout, "Failure should be a timeout"); + + // Non-zero status + TestableAudezeMaxwell rejected; + rejected.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x01, 0x0B, 0x00, 0x0A })); + auto error = rejected.getChatmix(nullptr); + ASSERT_TRUE(error.hasError(), "Rejected read should fail"); + ASSERT_TRUE(error.error().code == DeviceError::Code::ProtocolError, "Failure should be a protocol error"); + + std::cout << " [OK] Audeze Maxwell reply matching verified" << std::endl; +} + +void testAudezeMaxwellSidetone() +{ + std::cout << " Testing Audeze Maxwell sidetone..." << std::endl; + + // Capture: level 9 while sidetone is switched off + TestableAudezeMaxwell device; + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x2C, 0x00, 0x00, 0x09 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x00 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x2D, 0x00, 0x00, 0x1F })); + + auto off = device.getSidetone(nullptr); + ASSERT_TRUE(off.hasValue(), "Sidetone should be read"); + ASSERT_TRUE(off->is_muted, "Disabled sidetone should be muted"); + ASSERT_EQ(0, static_cast(off->current_level), "Disabled sidetone reports level 0"); + ASSERT_EQ(9, static_cast(off->device_level), "Stored level should be kept"); + ASSERT_EQ(31, static_cast(off->device_max), "Maximum should come from the headset"); + + const std::vector max_reply = maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x2D, 0x00, 0x00, 0x0F }); + const std::vector level_ack = maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, 0x00 }); + const std::vector toggle_ack = maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x82, 0x2C, 0x00, 0x07, 0x00 }); + const std::vector state_on = maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x01 }); + const std::vector state_off = maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x00 }); + const std::vector level_write = { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, 0x0F }; + const std::vector toggle = { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x82, 0x2C, 0x07, 0x00 }; + + // Writing the level switches sidetone on, so the state is only checked, not written + TestableAudezeMaxwell setter; + setter.hid.replies.emplace_back(max_reply); + setter.hid.replies.emplace_back(level_ack); + setter.hid.replies.emplace_back(state_on); + + auto set = setter.setSidetone(nullptr, 128); + ASSERT_TRUE(set.hasValue(), "Sidetone should be set"); + ASSERT_EQ(15, static_cast(set->device_level), "128 should map to the maximum the headset reported"); + ASSERT_EQ(3, static_cast(setter.hid.writes.size()), "Maximum query, level and state query"); + ASSERT_TRUE(maxwellWritePrefix(setter.hid.writes[1], 12) == level_write, "Level write"); + ASSERT_TRUE(maxwellWritePrefix(setter.hid.writes[2], 9) != maxwellWritePrefix(toggle, 9), "An enabled sidetone must not be toggled"); + + // Still off after the level write: toggled once and read back + TestableAudezeMaxwell still_off; + for (const auto& reply : { max_reply, level_ack, state_off, toggle_ack, state_on }) { + still_off.hid.replies.emplace_back(reply); + } + ASSERT_TRUE(still_off.setSidetone(nullptr, 128).hasValue(), "Sidetone should be switched on"); + ASSERT_EQ(5, static_cast(still_off.hid.writes.size()), "Maximum, level, state, toggle, state"); + ASSERT_TRUE(maxwellWritePrefix(still_off.hid.writes[3], 11) == toggle, "Toggle"); + + // Level 0 switches an enabled sidetone off and keeps the stored level + TestableAudezeMaxwell disable; + for (const auto& reply : { max_reply, state_on, toggle_ack, state_off }) { + disable.hid.replies.emplace_back(reply); + } + auto disabled = disable.setSidetone(nullptr, 0); + ASSERT_TRUE(disabled.hasValue(), "Sidetone should be switched off"); + ASSERT_TRUE(disabled->is_muted, "Level 0 is muted"); + ASSERT_EQ(4, static_cast(disable.hid.writes.size()), "Maximum, state, toggle, state"); + ASSERT_TRUE(maxwellWritePrefix(disable.hid.writes[2], 11) == toggle, "Toggle"); + for (const auto& write : disable.hid.writes) { + ASSERT_TRUE(maxwellWritePrefix(write, 10) != maxwellWritePrefix(level_write, 10), "The stored level must not be overwritten"); + } + + // Level 0 while it is already off must not toggle it back on + TestableAudezeMaxwell already_off; + already_off.hid.replies.emplace_back(max_reply); + already_off.hid.replies.emplace_back(state_off); + ASSERT_TRUE(already_off.setSidetone(nullptr, 0).hasValue(), "Sidetone should stay off"); + ASSERT_EQ(2, static_cast(already_off.hid.writes.size()), "Maximum and state query only"); + + // A lost toggle acknowledgement must not repeat the toggle: that would undo it + TestableAudezeMaxwell lost_ack; + lost_ack.hid.replies.emplace_back(max_reply); + lost_ack.hid.replies.emplace_back(state_on); + lost_ack.hid.replies.emplace_back(std::vector {}); + lost_ack.hid.replies.emplace_back(state_off); + ASSERT_TRUE(lost_ack.setSidetone(nullptr, 0).hasValue(), "The read back state decides"); + ASSERT_EQ(4, static_cast(lost_ack.hid.writes.size()), "The toggle is sent once"); + + // A headset that never changes state is an error + TestableAudezeMaxwell stuck; + for (const auto& reply : { max_reply, state_on, toggle_ack, state_on, toggle_ack, state_on }) { + stuck.hid.replies.emplace_back(reply); + } + ASSERT_TRUE(stuck.setSidetone(nullptr, 0).hasError(), "A state that does not change should fail"); + + // A setter without acknowledgement fails + TestableAudezeMaxwell unacknowledged; + auto failed = unacknowledged.setNoiseFilter(nullptr, 1); + ASSERT_TRUE(failed.hasError(), "Missing acknowledgement should fail"); + + std::cout << " [OK] Audeze Maxwell sidetone verified" << std::endl; +} + +void testAudezeMaxwellInactiveTime() +{ + std::cout << " Testing Audeze Maxwell inactive time..." << std::endl; + + const std::vector ack = maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x82, 0x2C, 0x00, 0x01, 0x00 }); + + // 4 minutes rounds up to 5 minutes = 300 s = 0x012C + TestableAudezeMaxwell device; + device.hid.replies.emplace_back(ack); + ASSERT_TRUE(device.setInactiveTime(nullptr, 4).hasValue(), "Inactive time should be set"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[0], 19) == std::vector { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00, 0x01, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x2C, 0x01 }), "Five minute request should match the documented bytes"); + + // 0 disables it + device.hid.replies.emplace_back(ack); + ASSERT_TRUE(device.setInactiveTime(nullptr, 0).hasValue(), "Inactive time should be disabled"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[1], 19) == std::vector { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }), "Disable request"); + + // Above 240 minutes the longest setting is used: 360 minutes = 21600 s = 0x5460 + device.hid.replies.emplace_back(ack); + ASSERT_TRUE(device.setInactiveTime(nullptr, 250).hasValue(), "Inactive time should be set"); + ASSERT_EQ(0x60, static_cast(device.hid.writes[2][13]), "Seconds low byte"); + ASSERT_EQ(0x54, static_cast(device.hid.writes[2][14]), "Seconds high byte"); + + // A rejected write is an error + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x82, 0x2C, 0x01, 0x01, 0x00 })); + ASSERT_TRUE(device.setInactiveTime(nullptr, 30).hasError(), "Non-zero status should fail"); + + std::cout << " [OK] Audeze Maxwell inactive time verified" << std::endl; +} + +static std::vector maxwellEqTemplate() +{ + std::vector ui(AudezeMaxwell::EQ_SETTINGS_SIZE, 0); + ui[0] = 1; + ui[1] = 0xA5; // Uninterpreted header and band fields must survive an edit. + for (size_t i = 0; i < AudezeMaxwell::EQ_FREQUENCIES.size(); ++i) { + const size_t base = 5 + 18 * i; + ui[base] = static_cast(i); + ui[base + 1] = 0x01; + ui[base + 14] = 0x5A; + for (size_t byte = 0; byte < 4; ++byte) { + const auto value = static_cast(AudezeMaxwell::EQ_FREQUENCIES[i] * 100); + ui[base + 2 + byte] = static_cast(value >> (8 * byte)); + ui[base + 10 + byte] = static_cast(value >> (8 * byte)); + } + } + return ui; +} + +static void queueMaxwellMessage(TestableAudezeMaxwell& device, const std::vector& body) +{ + auto reports = AudezeMaxwell::buildReports(AudezeMaxwell::buildMessage(AudezeMaxwell::TYPE_RESPONSE, body)); + for (auto& report : reports) { + report[0] = AudezeMaxwell::REPORT_ID_IN; + device.hid.replies.emplace_back(std::vector(report.begin(), report.end())); + } +} + +static void queueMaxwellEqSlot(TestableAudezeMaxwell& device, uint8_t slot, const std::vector& ui) +{ + std::vector body { 0x0C, 0x0A, 0, slot, 0xEF, static_cast(ui.size()), 0 }; + body.insert(body.end(), ui.begin(), ui.end()); + queueMaxwellMessage(device, body); +} + +void testAudezeMaxwellEqCoefficients() +{ + const std::vector bands { { 125, -3, 100 }, { 1000, 6, 700 }, { 8000, -12, 5000 } }; + // Independent golden outputs from installed AirohaPeqLibrary.dll. + const std::array goldens { + "0300906b0022829c00d2735d00a49a8500724c770095ff7f00ffc8800065797e008b1881003fd97d00d1ff7f00ff2fd100d71c60000cdddd001a6b2300ee94030062", + "0300906b0022d29b005e855e00b812850021fa7700d8ff7f00ffb78000d1997e001701810044067e002cff7f00ffa4c700dd6c61008b70d600ed3c26004b9c03008a", + "0300906b00225b9800604064000d89820091907b0035ff7f00ff638000343c7f002e8b80006deb7e00bdff7f00ffa09c00a20d6c006a5baf00f58e3f0076ec0300ae", + "0300906b002207980075d364002d4f820099ea7b00e2ff7f00ff5b8000124c7f000c8080000b027f0019ff7f00ff329900fb686d008f7eab00872843005ef8030072" + }; + const std::array rates { 44100, 48000, 88200, 96000 }; + for (size_t i = 0; i < rates.size(); ++i) { + auto bytes = AudezeMaxwell::eqCoefficientsForRate(bands, rates[i]); + ASSERT_TRUE(bytes.hasValue(), "Coefficient generation succeeds"); + std::string hex; + for (auto byte : *bytes) { + hex += std::format("{:02x}", byte); + } + ASSERT_TRUE(hex == goldens[i], "Portable coefficients match independent DLL output"); + } + auto ui = maxwellEqTemplate(); + auto parsed = AudezeMaxwell::readEqBands(ui); + ASSERT_TRUE(parsed.hasValue(), "Ten band template parses"); + auto payload = AudezeMaxwell::eqCoefficients(*parsed); + ASSERT_TRUE(payload.hasValue(), "Four sample-rate payload builds"); + ASSERT_EQ(844u, payload->size(), "Ten-band coefficient payload length"); + ASSERT_EQ(4, static_cast((*payload)[0]), "Four sample rates"); + for (size_t i = 0; i < 4; ++i) { + ASSERT_EQ((std::array { 1, 2, 5, 6 })[i], static_cast((*payload)[4 + 210 * i]), "Sample-rate ID"); + ASSERT_EQ(103, static_cast((*payload)[6 + 210 * i]), "Word count includes bands and gain"); + } + ui.pop_back(); + ASSERT_TRUE(AudezeMaxwell::readEqBands(ui).hasError(), "Reject truncated settings"); + ui = maxwellEqTemplate(); + ui[17] = 0xFF; + ASSERT_TRUE(AudezeMaxwell::readEqBands(ui).hasError(), "Reject invalid bandwidth"); + ASSERT_TRUE(AudezeMaxwell::eqCoefficientsForRate(bands, 32000).hasError(), "Reject unsupported sample rate"); + auto invalid = bands; + invalid[0].gain = std::numeric_limits::quiet_NaN(); + ASSERT_TRUE(AudezeMaxwell::eqCoefficients(invalid).hasError(), "Reject nonfinite coefficients"); +} + +void testAudezeMaxwellCustomEq() +{ + for (uint8_t slot = 0; slot < 4; ++slot) { + TestableAudezeMaxwell device; + auto original = maxwellEqTemplate(); + auto expected = original; + EqualizerSettings settings(std::vector(10, 0)); + settings.bands[1] = -3; + const auto gain = static_cast(int32_t(-300)); + for (size_t byte = 0; byte < 4; ++byte) { + expected[5 + 18 + 6 + byte] = static_cast(gain >> (8 * byte)); + } + queueMaxwellMessage(device, { 0x01, 0x09, 0x00, 0x00, 0x00, static_cast(slot + 7) }); + queueMaxwellEqSlot(device, slot, original); + queueMaxwellMessage(device, { 0x03, 0x0E, 0 }); + queueMaxwellMessage(device, { 0x03, 0x0A, 1 }); + // Wrong slot acknowledgement is ignored. + queueMaxwellMessage(device, { 0x0D, 0x0A, 0, static_cast(slot + 1), 0xEF }); + queueMaxwellMessage(device, { 0x0D, 0x0A, 0, slot, 0xEF }); + queueMaxwellMessage(device, { 0x03, 0x0A, 1 }); + queueMaxwellMessage(device, { 0x0D, 0x0A, 0, static_cast(0x2C + slot), 0xE4 }); + queueMaxwellEqSlot(device, slot, expected); + auto expected_bands = AudezeMaxwell::readEqBands(expected); + auto coefficients = AudezeMaxwell::eqCoefficients(*expected_bands); + std::vector coefficient_reply { 0x0C, 0x0A, 0, static_cast(0x2C + slot), 0xE4, 0x4C, 3 }; + coefficient_reply.insert(coefficient_reply.end(), coefficients->begin(), coefficients->end()); + queueMaxwellMessage(device, coefficient_reply); + ASSERT_TRUE(device.setEqualizer(nullptr, settings).hasValue(), "Custom EQ is saved and verified"); + + std::vector stream; + std::vector messages; + for (const auto& write : device.hid.writes) { + ASSERT_EQ(62u, write.size(), "EQ report size"); + ASSERT_EQ(0x80, static_cast(write[2]), "Headset route"); + const auto fragment = AudezeMaxwell::reportFragment(write); + stream.insert(stream.end(), fragment.begin(), fragment.end()); + for (auto message = AudezeMaxwell::extractMessage(stream); !message.empty(); message = AudezeMaxwell::extractMessage(stream)) { + messages.push_back(std::move(message)); + } + } + ASSERT_TRUE(stream.empty(), "No incomplete output message"); + ASSERT_EQ(9u, messages.size(), "Preset query, slot read, five update commands and two readbacks"); + ASSERT_EQ(40u, device.hid.writes.size(), "Fragmented EQ report count"); + ASSERT_EQ(851u, messages[2].size(), "Realtime coefficient message"); + ASSERT_TRUE((messages[3] == std::vector { 5, 0x5A, 4, 0, 3, 0x0A, 185, 0 }), "Prepare UI byte count, not key"); + ASSERT_TRUE(std::equal(expected.begin(), expected.end(), messages[4].begin() + 8), "Only requested gains change in stored UI data"); + ASSERT_TRUE((messages[5] == std::vector { 5, 0x5A, 4, 0, 3, 0x0A, 0x4C, 3 }), "Prepare coefficient byte count"); + ASSERT_EQ(0x2C + slot, static_cast(messages[6][6]), "Correct coefficient slot"); + ASSERT_TRUE(std::equal(messages[2].begin() + 7, messages[2].end(), messages[6].begin() + 8), "Applied and saved coefficients are identical"); + } + AudezeMaxwell2 other_model; + ASSERT_TRUE(!(other_model.getCapabilities() & B(CAP_EQUALIZER)), "Original EQ must not be advertised for Maxwell 2"); + ASSERT_TRUE(other_model.setEqualizer(nullptr, EqualizerSettings {}).hasError(), "Maxwell 2 does not inherit the writer"); +} + +void testAudezeMaxwellEqFailures() +{ + const EqualizerSettings flat(std::vector(10, 0)); + for (float value : { -13.0f, 13.0f, 0.5f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN() }) { + TestableAudezeMaxwell device; + auto invalid = flat; + invalid.bands[0] = value; + ASSERT_TRUE(device.setEqualizer(nullptr, invalid).hasError(), "Reject invalid gains"); + ASSERT_TRUE(device.hid.writes.empty(), "Validate before any USB traffic"); + } + TestableAudezeMaxwell wrong_count; + ASSERT_TRUE(wrong_count.setEqualizer(nullptr, EqualizerSettings {}).hasError(), "Reject wrong band count"); + ASSERT_TRUE(wrong_count.hid.writes.empty(), "Wrong count sends nothing"); + TestableAudezeMaxwell builtin; + queueMaxwellMessage(builtin, { 1, 9, 0, 0, 0, 1 }); + ASSERT_TRUE(builtin.setEqualizer(nullptr, flat).hasError(), "Never overwrite an unspecified custom slot"); + ASSERT_EQ(1u, builtin.hid.writes.size(), "Built-in preset only queried"); + // Stop on failure at every update stage; never retransmit NV writes. + const std::array, 5> acknowledgements { + std::vector { 3, 0x0E, 0 }, { 3, 0x0A, 0 }, { 0x0D, 0x0A, 0, 0, 0xEF }, + { 3, 0x0A, 0 }, { 0x0D, 0x0A, 0, 0x2C, 0xE4 } + }; + const std::array write_counts { 17, 18, 22, 23, 38 }; + for (size_t stage = 0; stage < acknowledgements.size(); ++stage) { + for (bool timeout : { false, true }) { + TestableAudezeMaxwell device; + queueMaxwellMessage(device, { 1, 9, 0, 0, 0, 7 }); + queueMaxwellEqSlot(device, 0, maxwellEqTemplate()); + for (size_t i = 0; i < stage; ++i) { + queueMaxwellMessage(device, acknowledgements[i]); + } + if (!timeout) { + auto refused = acknowledgements[stage]; + if (stage == 1 || stage == 3) { + refused.pop_back(); // Reclaim's byte 6 is not a status. + } else { + refused[2] = 1; + } + queueMaxwellMessage(device, refused); + } + auto result = device.setEqualizer(nullptr, flat); + ASSERT_TRUE(result.hasError(), "Missing or rejected ACK fails"); + ASSERT_EQ(write_counts[stage], device.hid.writes.size(), "No retry or later write after a failure"); + } + } + TestableAudezeMaxwell malformed; + queueMaxwellMessage(malformed, { 1, 9, 0, 0, 0, 7 }); + queueMaxwellEqSlot(malformed, 0, std::vector(10, 0)); + ASSERT_TRUE(malformed.setEqualizer(nullptr, flat).hasError(), "Malformed slot is rejected before writes"); + ASSERT_EQ(2u, malformed.hid.writes.size(), "Only queries before malformed slot failure"); + + TestableAudezeMaxwell readback; + queueMaxwellMessage(readback, { 1, 9, 0, 0, 0, 7 }); + queueMaxwellEqSlot(readback, 0, maxwellEqTemplate()); + for (const auto& ack : acknowledgements) { + queueMaxwellMessage(readback, ack); + } + auto wrong_ui = maxwellEqTemplate(); + wrong_ui[0] ^= 1; + queueMaxwellEqSlot(readback, 0, wrong_ui); + ASSERT_TRUE(readback.setEqualizer(nullptr, flat).hasError(), "Readback mismatch must not report success"); + + TestableAudezeMaxwell coefficient_readback; + queueMaxwellMessage(coefficient_readback, { 1, 9, 0, 0, 0, 7 }); + const auto original = maxwellEqTemplate(); + queueMaxwellEqSlot(coefficient_readback, 0, original); + for (const auto& ack : acknowledgements) { + queueMaxwellMessage(coefficient_readback, ack); + } + queueMaxwellEqSlot(coefficient_readback, 0, original); + auto bands = AudezeMaxwell::readEqBands(original); + auto coefficients = AudezeMaxwell::eqCoefficients(*bands); + std::vector reply { 0x0C, 0x0A, 0, 0x2C, 0xE4, 0x4C, 3 }; + reply.insert(reply.end(), coefficients->begin(), coefficients->end()); + reply.back() ^= 1; + queueMaxwellMessage(coefficient_readback, reply); + ASSERT_TRUE(coefficient_readback.setEqualizer(nullptr, flat).hasError(), "Coefficient mismatch must not report success"); + ASSERT_EQ(40u, coefficient_readback.hid.writes.size(), "Coefficient readback failure does not retry writes"); +} + void runAllProtocolTests() { std::cout << "\n============================================" << std::endl; @@ -1388,6 +1941,16 @@ void runAllProtocolTests() runTest("Logitech PRO X2 EQ Quantization", testLogitechProX2OnboardEqCoefficientQuantization); runTest("Logitech PRO X2 Onboard EQ Payload", testLogitechProX2OnboardEqPayloadBuilding); + std::cout << "\n=== Audeze Maxwell Protocol ===" << std::endl; + runTest("Audeze Maxwell Framing", testAudezeMaxwellFraming); + runTest("Audeze Maxwell Battery", testAudezeMaxwellBattery); + runTest("Audeze Maxwell Reply Matching", testAudezeMaxwellReplyMatching); + runTest("Audeze Maxwell Sidetone", testAudezeMaxwellSidetone); + runTest("Audeze Maxwell Inactive Time", testAudezeMaxwellInactiveTime); + runTest("Audeze Maxwell EQ Coefficients", testAudezeMaxwellEqCoefficients); + runTest("Audeze Maxwell Custom EQ", testAudezeMaxwellCustomEq); + runTest("Audeze Maxwell EQ Failures", testAudezeMaxwellEqFailures); + std::cout << "\n=== Plantronics Protocol ===" << std::endl; runTest("Plantronics BT600 Battery Parsing", testPlantronicsBT600BatteryParsing); runTest("Plantronics BT600 Reply Matching", testPlantronicsBT600ReplyMatching);