Я почти каждый день слушаю музыку на смартфоне и использую кнопки управления на гарнитуре. Но мне всегда не нравилась одна вещь. Я прихожу домой, продолжая прослушивание, гарнитура подключается к домашнему ПК — и внезапно кнопки перестают работать.

Конечно, я погуглил решение этой проблемы. К сожалению, на Windows эта замечательная функция не слишком поддерживается. Пара минут поиска дали только мутные упоминания на Stack Overflow о звуковых картах и сообщения некоторых людей, что на их ноутбуках всё работает нормально.

Меня это не испугало — и я решил принять проблему как интересный вызов: можно ли создать какую-то программу для активации кнопок управления, если аппаратной поддержки для них вообще нет? Ответ — да, можно. И вот как сделать это за полчаса.

Как работают кнопки гарнитуры Android


Первое, что нужно понять — как работают кнопки гарнитуры. Быстрый поиск в интернете нашёл эту спецификацию из документации Android. Там есть диаграмма.



Как можно понять, при нажатии кнопки на гарнитуре замыкается цепь на одном из резисторов. Особого внимания заслуживает Кнопка A (Play/Pause/Hook) с сопротивлением 0 Ом, то есть замыканием микрофона. Если мы способны обнаружить короткое замыкание микрофона, то так сможем определить нажатие кнопки Play/Pause.

Проверка гипотезы


Прежде чем начать программировать, хотелось бы проверить разумность наших рассуждений в принципе. То есть того, что по сигналу с микрофона можно определить нажатие кнопки Play/Pause. К счастью, для этого достаточно просто записать звук на компьютере и посмотреть на результат. Я запустил Audacity, нажал во время записи кнопку Play/Pause — и получил такой сигнал.


Бинго

Как видим, нажатие кнопки очевидно отражается в форме сигнала: внезапное падение до ?1 с последующим внезапным переходом к 1 и постепенным уменьшением до 0. Интуитивно по спецификации я бы предположил, что сигнал подскочит до 1 и останется там, пока кнопку не отпустить, но в реальности выглядит иначе. Тем не менее, такую картинку всё равно легко обнаружить, если захватить аудиопоток с микрофона.

Захват звука средствами Python


Зная способ, как обнаружить нажатие кнопок на гарнитуре, можно подумать о главной цели: как управлять плеером на рабочем столе с помощью кнопок гарнитуры.

Первый шаг — обнаружение нажатия кнопки. Для этого нужно захватить аудиопоток с микрофона и обнаружить отчётливую подпись, которую мы видели ранее. Для простоты реализуем решение на Python. После ещё одного небольшого поиска в интернете я нашёл пакет под названием sounddevice, который позволяет абстрагироваться от самой трудной части — реального аудиозахвата с микрофона.

Немножко кодирования даёт нам следующее:

import sounddevice as sd

SAMPLE_RATE = 1000 # Sample rate for our input stream
BLOCK_SIZE = 100 # Number of samples before we trigger a processing callback

class HeadsetButtonController:
    def process_frames(self, indata, frames, time, status):
        mean = sum([y for x in indata[:] for y in x])/len(indata[:])

        print(mean)

    def __init__(self):
        self.stream = sd.InputStream(
            samplerate=SAMPLE_RATE,
            blocksize=BLOCK_SIZE,
            channels=1,
            callback=self.process_frames
        )
        self.stream.start()

if __name__ == '__main__':
    controller = HeadsetButtonController()

    while True:
        pass

Такой код непрерывно выдаёт среднее значение каждой партии образцов. Мы установили частоту дискретизации 1000, что ужасно мало для обработки звука (обычно используется 44100), но нам в реальности не нужна большая точность. Размер блока определяет, сколько сэмплов в буфере инициируют обратный вызов. Опять же, мы установили очень низкие значения. Размер блока 100 и частота дискретизации 1000 фактически означает срабатывание 10 раз в секунду, где при каждом вызове обрабатывается только 100 сэмплов.

Определение нажатия кнопки: наверное, слишком простой способ


Теперь мы захватываем аудиопоток и можно реализовать реальный механизм для обнаружения нажатия кнопки. Напомним, что сигнал подскакивает до 1 всякий раз при нажатии. Это подсказывает самый простой способ обнаружения: если у N последовательных блоков значения сигнала выше 0,9, то есть нажатие.

Реализуем алгоритм в нашей функции:

import sounddevice as sd

SAMPLE_RATE = 1000 # Sample rate for our input stream
BLOCK_SIZE = 100 # Number of samples before we trigger a processing callback
PRESS_SECONDS = 0.2 # Number of seconds button should be held to register press
PRESS_SAMPLE_THRESHOLD = 0.9 # Signal amplitude to register as a button press

BLOCKS_TO_PRESS = (SAMPLE_RATE/BLOCK_SIZE) * PRESS_SECONDS

...

def process_frames(self, indata, frames, time, status):
    mean = sum([y for x in indata[:] for y in x])/len(indata[:])

    if mean < PRESS_SAMPLE_THRESHOLD:
        self.times_pressed += 1

        if self.times_pressed > BLOCKS_TO_PRESS and not self.is_held:
            # The button was pressed!
            self.is_held = True
    else:
        self.is_held = False
        self.times_pressed = 0

...

По сути мы запустили внутренний счётчик, сколько обработанных блоков отвечают пороговому требованию, которое просто установили на 0,9, предусмотрев неизбежное зашумление образца. Если блок не удовлетворяет требованию, счётчик сбрасывается — и мы начинаем заново. Переменная is_held отслеживает срабатывания, чтобы не регистрировать их многократно, если кнопка не отпускается.

Управление воспроизведением в Windows


Теперь осталось только заменить в реальном коде комментарий “The button was pressed!”, чтобы управлять воспроизведением звука в Windows. Снова погуглим, чтобы разобраться, как это сделать: оказывается, можно управлять воспроизведением, имитируя нажатие клавиш соответствующими кодами виртуальных клавиш.

Оказалось, что имитировать нажатия клавиш очень легко с помощью пакета pywin32, который является просто оболочкой Python для Windows API. Собрав всё вместе, мы можем создать следующую функцию:

import win32api
import win32con

VK_MEDIA_PLAY_PAUSE = 0xB3

def toggle_play():
    win32api.keybd_event(VK_MEDIA_PLAY_PAUSE, 0, 0, 0)

И у нас получилось! Обращение к функции toggle_play в том месте кода, где был комментарий “The button was pressed!”, позволяет управлять любым медиаплеером в Windows с помощью кнопок на гарнитуре Android.

Тесты показали, что код работает на удивление хорошо. Единственное различие между функциональностью на Android и Windows заключается в небольшой задержке при нажатии на кнопку, но с этим можно жить.


И вот что получилось

Скрипт Python состоит из 51 строки, которые активируют кнопки гарнитуры Android в Windows. Окончательный исходный код этого проекта лежит на Github.

Погодите, это ещё не всё!


После счастливого использования программы в течение нескольких часов я заметил серьёзную проблему:



Программа использует почти 30% CPU! Очевидно, это неприемлемо при длительной работе, что-то нужно делать. Посмотрев на код, я понял, что основной поток находится в состоянии ожидания в основном цикле, хотя там ничего не происходит. Наиболее логичное решение — просто усыпить поток навсегда: поскольку колбэк вызывается автоматически, нам всё равно не нужен цикл.

from time import sleep

if __name__ == '__main__':
    controller = HeadsetButtonController()

    while True:
        sleep(10)



Я также не хотел запускать скрипт Python вручную после каждого запуска компьютера. К счастью Python для Windows поставляется с полезной утилитой pythonw.exe, которая запускает процесс «демона» без подключенного терминала. Размещаем ярлык к этому процессу в каталоге Microsoft\Windows\Start Menu\Programs\Startup, указав наш скрипт в качестве первого аргумента — тогда приложение автоматически запускается и незаметно работает в фоновом режиме.

Комментарии (20)


  1. mayorovp
    16.07.2018 16:32

    Вот так: while True: pass никогда не следует даже пытаться писать! Вариант while True: sleep(10) чуть по-лучше, но все еще странный.


    Если нужно уснуть навсегда (до прерывания с клавиатуры или завершения процесса) — подойдет вот такой вариант (источник: https://stackoverflow.com/a/48631852):


    from threading import Event
    
    Event().wait()


  1. DollaR84
    16.07.2018 20:33

    Интересно было почитать, спасибо.
    Насчет нажатия программно комбинаций клавиш. Сам использовал pywin32 модуль, а потом как-то попался модуль keyboard. Вроде проще и довольно удобный. Не знаю может есть какие-то недостатки в работе, мне пока не попадалось вроде.


  1. rdifb0
    16.07.2018 21:19
    +1

    Ужас. А отпускать кнопку кто будет, Иван Фёдорович Крузенштерн?

    def toggle_play():
        win32api.keybd_event(VK_MEDIA_PLAY_PAUSE, 0, 0, 0)
    

    Должно быть примерно так.
    def toggle_play():
        win32api.keybd_event(VK_MEDIA_PLAY_PAUSE, 0, 0, 0)
        win32api.keybd_event(VK_MEDIA_PLAY_PAUSE, 0, win32con.KEYEVENTF_KEYUP, 0)
    


    1. Lertmind
      17.07.2018 00:39

      Это перевод, нужно создавать pull request.
      Там не нужно time.sleep(0.05) между нажатием/отпусканием? Видел такое в некоторых примерах использования.


      1. DollaR84
        17.07.2018 10:31

        Тоже видел, но в своих программах делал без этой задержки и все работает прекрасно. Так что думаю вполне норм.


  1. nobletracer
    16.07.2018 21:20

    Это будет просто бесценная программа, если будет интегрироваться с браузером и стриминговыми сервисами.


  1. Lertmind
    17.07.2018 00:31

    Не знал, что в домашних ПК уже есть разъёмы с поддержкой гарнитуры.
    У меня переходника нет, пробовал вставлять две гарнитуры в разъём микрофона. Та, которая от Nokia и не работает на моём устройстве Android, смогла передать сигнал при нажатии, хотя записи звука нет. Регистрация нажатия срабатывала через раз, увеличил SAMPLE_RATE на 2 и стало нормально.


    1. immaculate
      17.07.2018 03:53

      У меня в 4-х летнем ноутбуке есть разъем с поддержкой гарнитуры. Только мне пока не удалось найти гарнитуру, которая к нему бы подходила (специально не искал, но телефонная не работает — звук не пишется).


    1. Eloev
      17.07.2018 07:12

      Звука в нокиевской гарнитуре нет из-за разных стандартов джека (OMTP и CTIA), по делу разница стандартов лишь в поменянных местами контактах GND и Mic. Я думаю, возьмёте любые другие наушники, стандарт у почти всех кроме нокии CTIA, и все будет хорошо.


    1. DollaR84
      17.07.2018 10:34

      У меня старенький Asus Vivobook еще года так 2012 если не ошибаюсь, или около того. Один аудио разъем для гарнитуры, совмещенный наушники с микрофоном.


  1. Alexeyslav
    17.07.2018 09:44

    Интуитивно по спецификации я бы предположил, что сигнал подскочит до 1 и останется там, пока кнопку не отпустить, но в реальности выглядит иначе.

    Зная устройство микрофонного входа я бы такое не предположил бы никогда. Для работы микрофона на входе поддерживается некоторая постоянная составляющая — по сути источник тока с ограниченным напряжением в +3В примерно. Уровень напряжения «нуля»(в пределах 1..2В) при подключенном микрофоне будет зависеть от самого микрофона, разброса параметров микрофона и т.д. чтобы с этим не парится по входу стоит ВЧ-фильтр на 2-10Гц(на дешёвых карточках может быть и выше граница) в виде конденсатора, что в итоге даёт наблюдаемую картину.
    И я бы в условие срабатывания включил бы проверку крутизны фронтов этого импульса, с вашей проверкой могут быть ложные срабатывания просто от громкого звука(микрофоны гарнитуры бывают довольно чувствительны и болтаются где ни попадя). Поэтому нужна полноценная частота семплирования и простейшая обработка сигнала.


  1. RomanGL
    17.07.2018 12:17

    С Bluetooth наушниками ничего такого нельзя сделать? У меня кнопки на наушниках (play/pause, next, previous) работают только в UWP приложениях и в iTunes.
    Может как-то можно обойти, чтобы вызывалось срабатывание обычных медиаклавиш клавиатуры?


  1. Mike-M
    17.07.2018 13:07

    Интересная статья, спасибо.
    Сам, правда, отказался от прослушивания музыки с ПК в пользу смартфона. Первый слишком прожорлив для этой задачи в плане энергопотребления.


  1. ser-mk
    17.07.2018 15:23

    Спасибо за перевод. И спасибо автору что не стал использовать нейросети для распознования сигнала кнопки (когда читал прям был уверен что сейчас начнется machine learning =)


  1. Loki3000
    18.07.2018 09:59

    Интересно, а любой ли набор резистивных кнопок можно подключить через вход наушников? А то те решения что я видел, были довольно дорогие и громоздкие.


    1. Alexeyslav
      18.07.2018 11:26

      А что мешает?


      1. Loki3000
        18.07.2018 11:35

        Ну не знаю… возможно, какое-то согласование уровней нужно или еще что-нибудь подобное…
        Просто есть же джойстики для магнитол.

        У них как раз миниджек в качестве разъема.
        image


        1. Alexeyslav
          18.07.2018 11:46

          Миниджек вообще ничего не значит. Там может быть то же самое что в телефонной гарнитуре или вовсе полностью цифровой интерфейс, просто сам разъём удобный. И всё же скорей всего там именно цифровой интерфейс. Подаёшь питание а на третьем выводе получаешь серии импульсов, соответствующих нажатым кнопкам и т.д. Или вообще двусторонний интерфейс с аутентификацией.
          Надо расковыривать, наблюдать осциллографом на живой магнитоле и потом делать выводы.
          Но технически, можно разобрать выкинуть всю начинку оттуда и сделать как в гарнитуре — на простых резисторах. Правда, с энкодерами могут быть проблемы — сложно будет отличать направление вращения. Ну и соответственно миниджек надо 4-х контактный. Короче, «каша из топора» получается.


          1. Loki3000
            18.07.2018 11:50

            Нет там цифрового интерфейса — обычный набор резистивных кнопок. Просто не слышал чтобы его подключали напрямую через вход микрофона — обычно использовали плату согласования. Потому вопрос и возник.


            1. Alexeyslav
              18.07.2018 13:48

              Без анализа схемы можно гадать бесконечно. Там могут быть номиналы резисторов совсем не те что подойдут для микрофонного входа.