Writing a primitive PC volume control over LAN using NodeJS

I’m probably like you, dear {{ $username }}, sometimes I like to watch a TV series from my computer, lying on the couch (don’t buy a TV for this, really), since the size of monitors now allows it. And you often have to deal with films in which the genius sound engineer makes the dialogues very quiet, and the effects, on the contrary, loud (even in the original voice acting it occurs, but with translations much more often), because of which you periodically have to jump up and pull the volume on the player or amplifier . In short, after another sound-aggressive blockbuster, I decided that it would be nice to do something about it. Thank God, I don’t need to tug on the BIOS remotely, so probably my problem can be solved somehow in a simple and interesting way…

Finding a solution

Ok, what are the options? You can follow the simple path and install any remote-control that has a client for mobile phones and score (AnyDesk, Remote Desktop, KiwiMote, thousands of them). But this is somehow not sporty, I don’t want to install something unnecessary on the phone, and I also have NodeJS running on my computer almost 24/7, so somehow the question arose “is it possible to pull WinAPI from a node?” . It turned out that it is possible, but not as easy as we would like.

If you start googling the issue, you can find out that the most common way to run C code on a node is to use the foreigin function interface implementation for Node – node-ffiwhich has not been updated for five years. Sometimes options on a slightly newer similar package are also described ffi-napi (last update three years ago) or using some other very rare package that works in a similar way. There is also an option to try installing a package specifically designed to control sound, so as not to write your own bike, for example the well-known node-audio.

The problem with all the above methods is that they work using a mechanism Node Addonsi.e. for their work in any case it will be necessary node-gyp. It would be possible to install it if it didn’t have problems with Windows 7 (yeah yeah, I'm fine, pass by), but to install it, it turns out, you also need to install the “Visual C++ Build Environment”, and not every version is suitable either, and in total all this will cost more than 2 GB of disk space (this is provided that you still need you will be able to get through the rest of the node-gyp installation crutches). Of course, I understand that for cool, advanced modern IT professionals, some measly 2 gigs is not a problem. And some people just have new Windows (I sympathize), or they already have it on their computer all the time. If you are one of those people, you can actually move on to the next chapter.

For me, installing so much garbage into the system just to toggle the volume control looks, to put it mildly, inadequate.

If you have your own solution, different from mine and without installing “VC++ Build Environment”, please tell us in the comments.

A bit of retrograde whining

What adds to the frustration is that even many developers rightly consider Seven to be an antiquity and are too lazy to support it, so installing the right environment is increasingly a freezing search for the right versions (more and more often I’m thinking about finally switching to Linux, but that’s not about that now). Situations especially set fire to the chair when the developers of some popular package suddenly decide that they I really need a new function for determining the system version in the installer, which is not in the seven, so they will no longer support it (the reason why it is impossible to install NodeJS older than version 14 without crutches). By the way, the time has come for eight fans to rejoice.

In fact, the option that I will describe will be useful not only for retrogrades, but will also probably appeal to fans of minimalistic, fast software, since this is a good option to run code without unnecessary crutches.

In short, I won’t take up your time any longer, the solution turned out to be like that joke – “there will always be an asian who will do better“, there was also a Chinese who wrote his implementation of node-ffi in Rust. Moreover, it works without any node-gyp. As far as I understand, he makes a build for each system independently before publishing it in npm, but the point is that we should do this you won’t have to, which means node-gyp is not needed.

The package is called ffi-rsand it is placed, obviously, by the command

npm i ffi-rs

Well, in fact, all that remains is to write a library layer for working with WinAPI in this area.

C code and fiddling with WinAPI

The next question is how to actually compile C so that it is simple and universal? I know that Visual Studio has been the standard for a long time, but since I usually write for MK, I don’t have studio on my computer. After going through the options, I came to the conclusion that the optimal option for me in this case is GCC, which weighs little, does not add unnecessary junk to the system and works great on the seven. You can download, for example right here. I have had the 11th x64 version for a long time, and I will use it.

Don't forget to check that the path to the GCC binary is in Path, so that it is convenient to run the compiler from anywhere.

I apologize in advance to all WinAPI and C++ expertsthe last time I had to write something like this was about ten years ago, it’s quite possible that all this can (and should!) be made simpler and more beautiful, but for the sake of an example it will do just fine.

Code test.cpp with an example of volume control via 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;
    }
  }
}

I used the showDevices function only for debugging, but if desired, it would not be difficult to add support for multiple devices.

For the old version ffi-rswhich I used, for some reason some kind of error occurred when trying to return a string as the result of an operation, as a result of which the node simply crashed without any messages (most likely the problem was a mismatch of string types). I was too lazy to figure out what exactly was the reason, especially since there were no clear error reports about the crash at that time, so I simply used integer codes as the result. Perhaps I just did something wrong, or perhaps this is really some kind of bug that will be fixed in future versions. At a minimum, the author promised to add detailed crash messages, probably when they appear, I will correct the code in my version.

Maybe I’m wrong, but, in theory, all this work with devices is such a crutch only in seven, in the new windows, although all these legacy methods remain, there should have been better options long ago (maybe there are in seven, I just don’t know about them I know). Or maybe not – as always, I will be glad to add additions in comments.

We assemble this into the DLL we need with the following command (we run it from the source directory, which is obvious):

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

lwinmm is needed here because we are in showDevices we use waveInGetNumDevs

If you did everything correctly, you now have a dll with the required function. All that remains is to quickly sketch out a server in JS that will process requests for the required port and run this function.

An example of a trivial server on 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');
});

The code seems to be elementary and does not need comments

Don't forget to install this very express before starting, as well as all the necessary libraries:

npm i express node-windows

Plastic bag node-windows optional, I use it to install all this as a service and not have to start it manually every time, and also to keep logs in the Windows log. You don't have to install it for testing, but don't forget to remove it from the server code as well.

setup.js script code for installing as a service
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();

For deletion, the script at the beginning is almost similar, except that the event will be different:

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

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

The only thing left is to write a basic web muzzle to send control commands to the server

An example of the simplest index.html for a web muzzle
<!DOCTYPE html>
<html>
<head>
  <title>Volume</title>
  <link rel="apple-touch-icon" sizes="180x180" href="https://habr.com/apple-touch-icon.png">
  <link rel="icon" type="image/svg+xml" href="http://habr.com/favicon.svg" sizes="any" />
  <link rel="icon" type="image/png" sizes="32x32" href="http://habr.com/favicon-small.png">
  <link rel="manifest" href="http://habr.com/manifest.json">
  <link rel="mask-icon" href="http://habr.com/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>

If you wish, you can draw a more attractive web face, add a press of the spacebar key to stop playback (sometimes you fall asleep and are too lazy to get up to turn off the series, and the next episode starts automatically), I think you can easily cope with this, and my goal was to show a simple implementation queries to WinAPI from NodeJS, I hope this is useful to someone. The most trivial improvement that suggests itself is to get the current volume level when the page loads and immediately set the slider to the desired position.

All that remains is to put the files needed for the web muzzle into the folder publicserver scripts and installations in the folder scriptswell, add to package.json our project section with scripts:

All package.json that I got
{
  "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"
  }
}

Please note that here I still have older versions, ffi-rs has definitely become better since then.

Well, that’s all, you can start npm run setup from the project folder and open the address of the computer on which you are running on the network, taking into account the port.

For the lazy, I collected everything in one place: https://github.com/Psychosynthesis/AudioManager

Pay attention! I strongly advise against using this approach to actually control your computer over a network. At a minimum, authorization needs to be added here so that it at least looks like a normal solution. The computer on which I use this is on a network without access to the Internet.

In a word, everything written above is solely to show how convenient it is to pull C code from NodeJS. Good luck everyone!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *