Я, наверняка как и ты, дорогой {{ $username }}, люблю иногда посмотреть сериальчик с компа, лёжа на диване (не покупать же ради этого телек, в самом деле), благо размеры мониторов нынче позволяют. А ещё частенько приходится сталкиваться с фильмами, в которых гений-звукорежиссёр делает диалоги очень тихими, а эффекты наоборот громкими (даже в оригинальной озвучке встречается, но с переводами много чаще), из-за чего периодически приходится вскакивать и дёргать громкость на плеере или усилителе. Короче, после очередного саунд-агрессивного блокбастера, я решил что неплохо было б что-то с этим сделать. Необходимости дёргать BIOS удалённо у меня, слава богу, не стоит, так уж наверное мою проблему можно решить как-то и просто и интересно...

Поиск решения

Ok, какие варианты? Можно пойти по простому пути и поставить любой remote-control у которого есть клиент для мобилы и забить (AnyDesk, Remote Desktop, KiwiMote, тысячи их). Но это как-то не спортивно, не хочется ставить что-то лишнее на телефон, ну а ещё у меня на компе и так практически круглыми сутками крутится NodeJS, так что как-то сам собой назрел вопрос "можно ли из ноды дёргать WinAPI". Оказалось что можно, но не так просто, как хотелось бы.

Если начать гуглить по вопросу, можно выяснить, что самый распространённый способ запустить сишный код на ноде заключается в использовании реализации foreigin function interface для Node — node-ffi, которая не обновлялась уже лет пять. Иногда ещё описываются варианты на чуть более новом аналогичном пакете ffi-napi (последнее обновление три года назад) или с помощью ещё какого-нибудь совсем редкого пакета работающего по аналогичному пути. Ещё есть вариант попробовать поставить пакет, заточенный сразу на управление звуком, чтобы не писать свой велосипед, например небезызвестный node-audio.

Проблема у всех вышеописанных способов в том, что они работают с помощью механизма Node Addons, т.е. для их работы в любом случае будет необходим node-gyp. Можно было б его и поставить, если бы у него не было проблем с седьмой виндой (даа-да, мне норм, проходите мимо), вот только для его установки, оказывается, ещё нужно ставить и "Visual C++ Build Environment" причём тоже не каждая версия подойдёт, а суммарно всё это обойдётся ещё более чем в 2 Гб места на диске (это при условии что вам всё-таки удастся продраться через остальные костыли установки node-gyp). Я, конечно, понимаю, что для крутых продвинутых современных IT-профессионалов какие-то жалкие 2 гига не проблема. А у кого-то просто винда новая (сочувствую), либо оно и так на компе стоит постоянно. Если вы как раз из таких, можете собственно сразу переходить к следующей главе.

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

Если у вас есть свой вариант решения, отличный от моего и без установки "VC++ Build Environment", пожалуйста расскажите в комментариях.

Немного ретроградского нытья

Фрустрации добавляет то, что семёрку уже даже многие разработчики справедливо считают древностью и ленятся поддерживать, поэтому установка нужной среды всё чаще представляет собой вымораживающий поиск нужных версий (всё чаще думаю окончательно перейти на Linux, но сейчас не об этом). Особенно поджигают стул ситуации, когда разработчики какого-нибудь популярного пакета вдруг решают, что им очень нужна новая функция определения версии системы в установщике, которой нет в семёрке, так что поддерживать они её больше не будут (причина по которой нельзя без костылей поставить NodeJS старше 14-й версии). Кстати, любителям восьмёрки уже тоже настало время радоваться.

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

Короче, не буду дальше занимать ваше время, решение оказалось как в той шутке — "всегда найдётся азиат, который сделает лучше", тут тоже нашёлся китаец, написавший свою реализацию node-ffi на Rust. При этом работает оно без всякого node-gyp. Насколько я понимаю он делает билд под каждую систему самостоятельно перед публикацией в npm, но суть в том, что нам этого делать не придётся, а значит и node-gyp не нужен.

Называется пакет ffi-rs, и ставится он, что очевидно, командой

npm i ffi-rs

Ну и, собственно, всё дальше останется только написать библиотеку-прослойку для работы с WinAPI на сях.

Cишный код и ковыряния с WinAPI

Следующий вопрос - чем собственно компилить си, чтобы это было просто и универсально? Я знаю, что уже давно стандарт это Visual Studio, но так как я обычно пишу на сях для МК, студии у меня на компе нет. Путём перебора вариантов, я пришёл к тому, что оптимальный для меня в этом случае вариант это GCC, который мало весит, не ставит в систему лишнего хлама и отлично работает на семёрке. Загрузить можно, например вот тут. У меня уже давно стоит 11-я x64 версия, ею и воспользуюсь.

Не забудьте проверить что путь к бинарнику GCC есть в Path, чтоб удобно было запускать компилятор из любого места.

Я заранее извиняюсь перед всеми знатоками WinAPI и С++, мне последний раз приходилось что-то подобное писать лет десять назад, вполне возможно всё это можно (и нужно!) сделать проще и красивее, но для примера вполне сойдёт.

Код test.cpp с примером управления громкостью через WinAPI
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#include <Windows.h>
#include <mmsystem.h>
#include <initguid.h>
#include <mmdeviceapi.h>
#include <endpointvolume.h>
#include <functiondiscoverykeys_devpkey.h> // Need to use PKEY_Device_FriendlyName
static const char VOLUME_RANGE_ERR[] = "Volume must be a fractional number ranging from 0 to 1";
static const char NO_ENDPOINTS_ERR[] = "No endpoints found.";
static const char OK_MSG[] = "Ok";

extern "C" int setVolume(double newVolume) {
  if (newVolume < 0 || newVolume > 1) {
    std::cout<<VOLUME_RANGE_ERR<< std::endl;
    return 1;
  }
  HRESULT hr = S_OK;
  UINT count = 0;
  IMMDeviceEnumerator *pEnumerator = NULL;
  IMMDeviceCollection *pCollection = NULL;
  IMMDevice *pEndpoint = NULL;
  IPropertyStore *pProps = NULL;
  LPWSTR pwszID = NULL;
  CoInitialize(NULL);
  hr = CoCreateInstance(CLSID_MMDeviceEnumerator, NULL,CLSCTX_ALL, IID_IMMDeviceEnumerator, (void**)&pEnumerator);
  hr = pEnumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE,&pCollection);
  // ** use "eCapture" for microphones and "eRender" for speakers.
  hr = pCollection->GetCount(&count);
  if (count == 0) {
    std::cout << NO_ENDPOINTS_ERR << std::endl;
    return 2;
  }
  for (UINT i = 0; i < count; i++) {
    hr = pCollection->Item(i, &pEndpoint);
    hr = pEndpoint->GetId(&pwszID);
    IAudioEndpointVolume *endpointVolume = NULL;
    pEnumerator->GetDevice(pwszID, &pEndpoint);
    pEndpoint->Activate(__uuidof(IAudioEndpointVolume),CLSCTX_INPROC_SERVER, NULL, (LPVOID *)&endpointVolume);
    endpointVolume->SetMasterVolumeLevelScalar((float)newVolume, NULL);
    endpointVolume->Release();
  }
  return 0;
}

extern "C" void showDevices() {
  UINT numDevices = waveInGetNumDevs();
  std::cout << "Number of input devices: " << numDevices << std::endl;
  for (UINT i = 0; i < numDevices; i++) {
    WAVEINCAPS deviceInfo;
    if (waveInGetDevCaps(i, &deviceInfo, sizeof(deviceInfo)) == MMSYSERR_NOERROR) {
      std::cout << "Input device " << i << ": " << deviceInfo.szPname << std::endl;
      std::cout << "Channels: " << deviceInfo.wChannels << std::endl;
      std::cout << std::endl;
    }
  }
  numDevices = waveOutGetNumDevs();
  std::cout << "Number of output devices: " << numDevices << std::endl;
  for (UINT i = 0; i < numDevices; i++) {
    WAVEOUTCAPS deviceInfo;
    if (waveOutGetDevCaps(i, &deviceInfo, sizeof(deviceInfo)) == MMSYSERR_NOERROR) {
      std::cout << "Output device " << i << ": " << deviceInfo.szPname << std::endl;
      std::cout << "Channels: " << deviceInfo.wChannels << std::endl;
      std::cout << std::endl;
    }
  }
}

Функцию showDevices я использовал, только для отладки но при желании не сложно будет добавить поддержку нескольких устройств.

У старой версии ffi-rs, которую я использовал, почему-то возникала какая-то ошибка при попытке вернуть строку в качестве результата выполнения операции, в результате чего нода просто вылетала без каких-либо сообщений (скорее всего проблема была в несовпадении типов строк). Мне было лень разбираться в чём именно причина, тем более никаких внятных отчётов об ошибке при падении тогда не было, поэтому я просто использовал целочисленные коды в качестве результата. Возможно я просто что-то не так делал, а возможно это действительно какая-то ошибка, которая будет исправлена в будущих версиях. Как минимум, автор обещал добавить развернутые сообщения о падении, вероятно когда они будут, я исправлю код в своей версии.

Возможно я не прав, но, по идее, вся эта работа с устройствам такая костыльная только в семёрке, в новых форточках хотя и остались все эти легаси методы, уже давно должны быть варианты краше (аможет и в семёрке есть, просто я о них не знаю). А может и нет — как всегда буду рад дополнениям в камментах.

Собираем это в нужную нам DLL следующей командой (запускаем из каталога с исходником, что очевидно):

g++ -shared -o test.dll test.cpp -lole32 -lwinmm

lwinmm здесь нужна потому что мы в showDevices мы используем waveInGetNumDevs

Если вы всё сделали правильно, у вас теперь есть dll с необходимой функцией. Осталось всего лишь быстро набросать на JS сервер, который будет обрабатывать запросы на нужный порт и дёргать эту функцию.

Пример тривиального сервера на Express
const express = require('express');
const server = express();
const { load, RetType, ParamsType } = require('ffi-rs');
const EventLogger = require('node-windows').EventLogger;

const SERVER_PORT = 12345;
const logger = new EventLogger('New windows-event logger');

const messageCodes = {
  0: "Ok",
  1: "Volume must be a fractional number ranging from 0 to 1",
  2: "No endpoints found.",
  3: "Volume is not specified",
  5: "NodeJS AudioManager server start at port " + SERVER_PORT,
  42: "Something went wrong when tried using extern DLL (unknown error)",
};

// Add headers before the routes are defined
server.use(function (req, res, next) {
  res.setHeader('Access-Control-Allow-Origin', '*');
  // Возможно тут стоит добавить адрес компьютера на котором вы хотите запускать сервер для пущей безопасности
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
  // Мы используем этот же сервер для хостинга веб-интерфейса
  res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With, content-type');
  next();
});

server.use(express.json({ limit: '1mb' }));
server.use(express.static(__dirname + '/../public'));
// Возвращаем index.html из папки public при запросе http://127.0.0.1:SERVER_PORT/

server.post('/api/setvolume', function(request, response) {
  const input = request.body;
  let externalResult = 0;
  if (!input.hasOwnProperty('volume')) {
    response.status(422);
    response.send(messageCodes[3]);
    return;
  }
  const newVolume = parseFloat(input['volume']);
  if (isNaN(newVolume) || newVolume < 0 || newVolume > 1) {
    response.status(422);
    response.send(messageCodes[1]);
    return;
  }

  try {
    externalResult = load({
      library: __dirname + '/../test.dll', // path to the dynamic library file
      funcName: 'setVolume', // the name of the function to call
      retType: RetType.I32, // the return value type
      paramsType: [ParamsType.Double], // the parameter types
      paramsValue: [newVolume] // the actual parameter values
    });
  } catch(err) {
    logger.error('Something went wrong when tried using extern DLL: ' + err.name + ' ' + err.message, 42);
    console.error(err);
    response.send(err.message);
    return;
  }

  if (externalResult != 0) {
    console.error(messageCodes[externalResult]);
  }

  response.send(messageCodes[externalResult]);
});

server.listen(SERVER_PORT, () => {
  logger.info(messageCodes[5], 5); // Second param is event code, use your own
  console.log(messageCodes[5]);
  console.log('Open http://127.0.0.1:'+ SERVER_PORT +' in your browser');
});

Код будто бы элементарный, в комментариях не нуждается

Не забудьте поставить этот самый экспресс перед запуском, а также все необходимые библиотеки:

npm i express node-windows

Пакет node-windows опционален, я использую его чтобы установить всё это в качестве службы и не запускать руками каждый раз, а также для ведения логов в журнал винды. Для теста можете его не ставить, но не забудьте удалить также из кода сервера.

Код скрипта setup.js для установки в качестве службы
const path = require('path');
const Service = require('node-windows').Service;

// Создаем новый объект службы
const svc = new Service({
  name:'NodeJS AudioManager',
  description: 'NodeJS AudioManager as Windows Service',
  script: path.resolve(__dirname)+'\\server.js', // путь к приложению
  maxRetries: 10, // не перезапускать службу после 10 падений (защита от цикла)
});

// Слушаем событие 'install' и запускаем службу
svc.on('install',function(){
  svc.start();
});

// Устанавливаем службу
svc.install();

Для удаления скрипт в начале почти аналогичный, разве что событие будет другое:

// Слушаем событие 'uninstall', пишем сообщение
svc.on('uninstall', function(){
  console.log('Uninstall complete.');
  console.log('The service exists: ', svc.exists);
});

// Удаляем службу
svc.uninstall();

Единственное, что осталось, это написать элементарную веб-морду, чтобы слать на сервер команды управления

Пример простейшего index.html для веб-морды
<!DOCTYPE html>
<html>
<head>
  <title>Volume</title>
  <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
  <link rel="icon" type="image/png" sizes="32x32" href="/favicon-small.png">
  <link rel="manifest" href="/manifest.json">
  <link rel="mask-icon" href="/safari-pinned.svg" color="#5bbad5">
  <meta name="msapplication-TileColor" content="#00aba9">
  <meta name="theme-color" content="#418598">
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <style>
    html, body { margin: 0; background: #202020; color: #efefef; }
    body { overflow: hidden; }
    .container { margin: 0 auto; display: flex; flex-direction: column; max-width: 800px; height: 100vh; }
    .input-container { transform: rotate(270deg); display: flex; }
    h1 { text-align: center; }
    input { width: 400px; }

    @media (max-width: 700px) {
      .input-container { flex-grow: 1; }
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>Control PC volume</h1>
    <div class="input-container">
      <input type="range" min="0" max="1" step="0.02" value="0.5" onchange="onInputChange();" id="volume-change-input"/>
    </div>
  </div>
  <script>
    function onInputChange() {
      var newValue = document.getElementById("volume-change-input").value;
      fetch("/api/setvolume", {
        method: "post",
        headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
        //make sure to serialize your JSON body
        body: JSON.stringify({ volume: newValue })
      }).then( (response) => {
        //do something awesome that makes the world a better place
      });
    }
  </script>
</body>
</html>

При желании веб-морду можно нарисовать и посимпатичнее, добавить нажатие клавиши пробел чтобы остановить воспроизведение (иногда уже засыпаешь и лень вставать выключать сериал, а следующая серия включается автоматически), думаю вы без труда с этим справитесь, а моя цель была показать простой вариант выполнения запросов к WinAPI из NodeJS, надеюсь кому-то это пригодится. Самое тривиальное улучшение, которое напрашивается — получать текущий уровень громкости при загрузке страницы и сразу выставлять ползунок в нужное положение.

Оостанется закинуть нужные для вебморды файлы в папку public, скрипты сервера и установки в папку scripts, ну добавить в package.json нашего проекта раздел со скриптами:

Весь package.json, который у меня получился
{
  "name": "AudioManager",
  "version": "1.0.0",
  "description": "Control your computer's sound from any device on the network, without any additional applications.",
  "main": "server.js",
  "scripts": {
    "dev": "node scripts/server.js",
    "setup": "node scripts/setup.js",
    "uninstall": "node scripts/uninstall.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Psychosynthesis/AudioManager.git"
  },
  "homepage": "https://github.com/Psychosynthesis/SimpleServer#readme",
  "author": "Nick G.",
  "license": "MIT",
  "dependencies": {
    "express": "^4.18.2",
    "ffi-rs": "^1.0.18",
    "node-windows": "^1.0.0-beta.8"
  }
}

Обратите внимание, тут у меня старые версии ещё, ffi-rs точно лучше стала с тех пор.

Ну вот, собственно, и всё, можно запускать npm run setup из папке проекта и открывать в сети адрес компа, на котором запускаете, с учётом порта.

Для ленивых собрал всё в одну репу: https://github.com/Psychosynthesis/AudioManager

Обратите внимание! Я категорически не рекомендую использовать такой подход для реального управления вашим компьютером по сети. Как минимум сюда нужно добавить авторизацию, чтобы это было хотя бы похоже на нормальное решение. Компьютер на котором я это использую находится в сети без доступа к интернету.

Словом, всё написанное выше - исключительно чтобы показать, как удобно дёргать сишным кодом из NodeJS. Всем удачи!

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


  1. jonic
    04.11.2024 13:46

    С одной стороны интересно, с другой https://github.com/itchyny/volume-go + go http = результат за 15 минут и еще бинарик можно другу скинуть.

    Но в целом если зависимости не парят, то нода проще


    1. Psychosynthesis Автор
      04.11.2024 13:46

      Ещё бы Го выучить...


      1. jonic
        04.11.2024 13:46

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


    1. senchik
      04.11.2024 13:46

      на Delphi за 5 минут)


      1. jonic
        04.11.2024 13:46

        Неа, я за {}, а не эти ваши begin end tabulation flow


        1. HemulGM
          04.11.2024 13:46

          Как в go, c# и других языках с {}, в Delphi операторные скобки точно так же пишутся, вставляются и завершаются автоматически.


  1. BerkutEagle
    04.11.2024 13:46

    Kodi в качестве плеера + Kore (или web-интерфейс) для удалённого управления. И можно не вставая с дивана не только громкость регулировать.


    1. Psychosynthesis Автор
      04.11.2024 13:46

      Но это не прикольно


    1. arx3889
      04.11.2024 13:46

      Так автор же пишет, что

      всё написанное выше - исключительно чтобы показать, как удобно дёргать сишным кодом из NodeJS
      Это само по себе любопытно.
      Но если говорить про минимализм, я бы просто встроил в сишный код примитивный и достаточный http-ответчик.


  1. ionicman
    04.11.2024 13:46

    Я не понял зачем стрелять межконтинентальной ракетой по чижику?

    Базовый http-сервер на си занимает около экрана кода. Вариантов на гите - ворох, написать самому тоже дело совсем простое. Все равно ведь на си писать?

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

    Остаётся just because i can / for fan, видимо.


    1. Psychosynthesis Автор
      04.11.2024 13:46

      Может понадобиться вызвать сишный код из js, например для ускорения каких-либо вычислений. Само существование таких вещей как ffi мягко намекает, что это в принципе не какой-то невероятный кейс.

      Смысл был показать, как ещё это можно сделать.

      Сервер на крестах написать много ума не надо, я этим в институте на лабораторных занимался, лет десять назад.


  1. idd451289
    04.11.2024 13:46

    На самом деле можно затестить bun с их фичей нативного си. Глянуть можно тута
    Но вопрос поддержки семерки


    1. Psychosynthesis Автор
      04.11.2024 13:46

      Ну вообще, судя по докам выглядит круто, спасибо! Надо потыкать.