🔊 Kontroller lydstyrken pĂ„ din computer med et fysisk "volume-knob"!

@mikkelrask · July 02, 2024 · 15 min read

Skru' op!

Ovre i min dertil-indrettede potentiometer skuffe fandt jeg mig en 50k variabel modstand - et potentiometer - samt 3 nĂŠrmest pinligt tynde ledninger, og en Arduino Pro Micro og har lavet mig en fysisk volume kontrol til min computer.

Jeg har set mange forskellige varianter af lignende projekter, og hvor mange andre har lavet det med sÄkaldte rotary encoders som er digitale, og derfor nÊrmest endnu nemmere at have med at gÞre, end et analog komponent som en modstand er, sÄ var det ikke dét jeg ledte efter.

Jeg kan nemlig godt lide at min volume kontrol har stops, sÄledes at nÄr man rammer 0% eller 100% volume (These go to eleven), at man ikke kan dreje volume kontrolleren yderligere. PrÊcis som pÄ et old school stereoanlÊg, guitarforstÊrker eller hvad ved jeg.

SÄ uden at tÊnke alverden mere over diverse microcontrollers' begrÊnsninger, var min tankte at jeg blot ville lÊse potentiometerets vÊrdi, mappe vÊrdien til et sted imellem 0-100, og derfra bare fÄ arduinoen til at sende computeren samme kommando som jeg kan bruge til at sÊtte volumen til en specifik vÊrdi i terminalen. pactl bruger jeg typisk til dét-

-Lidt hurtig sÞgning pÄ nettet fik mig hurtigt til at indse, hvad jeg jo egentlig i forvejen udmÊrket vidste: det er bare ikke lige helt sÄdan arduinos kan interagere med et vÊrtssystem - ogsÄ selvom det havde vÊret praktisk.

Hm, nÄ! NÊste tanke var sÄ, at, ligesom fÞr, mÄle potentiometerets nuvÊrende vÊrdi sammenligne med forrige mÄling, og med en pro micro, som i modsÊtning til en klassisk Arduiono UNO ogsÄ kan agere HID (Human Input Device)/tastatur. Med et HID-bibliotek ville man sÄ kunne eksekvere samme kommando som tidligere nÊvnt, men denne gang ved at sende keystrokes der ville taste kommandoen for mig, eks i terminalen i stedet.

eww
Ja - mine egne tanker om lÞsningen var det samme! "Brother eww!" Det bliver rigtig hurtigt rigtig grimt pÄ nippet til ulÊkkert, og ville mest af alt minde om et eller andet ducky script/script-kitty payload. Det var allerede udelukket!

Det ville ogsĂ„ resultere i at der sĂ„ snart man justerede lyden, ville poppe et terminal vindue op pĂ„ ens skĂŠrm, som sĂ„ ville skulle vĂŠre der i n-antal milliseconder, imens pro micro'en sendte de nĂždvendige keystrokes der tilsammen udgĂžr kommandoen vi prĂžver at kĂžre, og herefter lukke ned igen...💀

Og hvad sĂ„ hvis man skruer rigtig hurtigt rigtig meget op? Ja, sĂ„ ville den skulle sende kommandoen for hver mĂ„ling pro microen registrerede imens jeg skruede op, og det ville bombardere enhver computer med terminal vinduer 💀

Men! Dét jeg kunne gÞre var sÄ at mÄle potentiometerets vÊrdi, sammenligne med den forrige mÄling, og i tilfÊlde af at var en difference pÄ mere end +/- 1% pÄ de to, at bruge pro micro'ens keyboard emulerings-egenskaber til at sÄ sende keystrokes for de volume taster der jo findes pÄ de fleste tastature - MEDIA_VOLUME_UP og MEDIA_VOLUME_DOWN.

ahaa
Her er udfordringen jo sÄ, at man med de taster jo ikke sÊtter volumen til en specifik vÊrdi, men istedet justerer den op eller ned, typisk med 2% af gangen. Det har ogsÄ den uheldige resultat, at hvis man tilslutter enheden til en computer hvor volumen er pÄ 100%, imens volume-knob'et er pÄ 0%, at jeg fÞrst ville skulle skrue op, fÞr jeg ville kunne skrue ned - pga. de stops, som jeg jo var sÄ insisterende pÄ at jeg ville have.

Men faktisk er det den overordnede idé, som firmwaren er endt med, i hvert fald i skrivende stund - og efter lidt justen frem og tilbage pÄ lidt grace time/hvor ofte jeg sender keystrokes i pro microens main loop, virker det faktisk vÊsentlig bedre end jeg ville have turdet hÄbe pÄ.

Jeg valgte at gÞre det pÄ den mÄde til trods for mine "strenge" krav, for at gÞre enheden 100% "OS-agnostisk", ligesom jeg jo selv er. Tilslutter jeg den til en vilkÄrlig Windows eller Mac computer, vil det fortsat fungere out-of-the-box, da de jo ligesom Linux blot ser enheden som et tastatur, der trykker pÄ den ene eller anden tast..

Men det havde lidt samme udfordring som fÞr - hvad mon hvis jeg igen skruer for hurtigt op, eller ned? Der ville alt efter hvor hurtigt jeg gjorde det, mÄske blive sendt et keystroke eller to undervejs, imens potentiometeret reelt kan have roteret eks. 75% eller hvad ved jeg, hvis jeg virkeligt giver den gas.

SÄ ville de to igen hurtigt ende ude af sync, hvor volumen eks kunne vÊre hÞj, imens potentiometeret var pÄ 0% eller for den sags skyld omvendt.

aha
SÄ i stedet for at blot bruge viden om, at "der var en difference <+/-1" til at sende et enkeltstÄende keystroke, kalkulerer vi selvfÞlgeligt den reele difference pÄ de to vÊrdier, og i et loop i stedet sender ét MEDIA_VOLUME_UP eller MEDIA_VOLUME_DOWN keystroke for hver anden difference i vÊrdierne der var pÄ denne mÄling og den forrige.

Vi sender kun hver anden gang, da tastatur volume tasterne typisk justerer med 2% af gangen, imens vores potentiomer jo er mappet til samtlige vĂŠrdier imellem 0-101.

Og som altid, prÞver jeg slet ikke at lade som om at jeg har opfundet den dybe tallerken eller skrevet et nyt framework, eller hvad ved jeg (i bunden af siden, linker jeg endda til en instructables how-to, der gÞr mere eller mindre det samme), men mere bare fortÊlle om udfordringerne man kan mÞde pÄ selv simple projekter som det her, og lidt om selve tankeprocessen der ender med at fÄ mig i mÄladd .

Den simple Êndring har dog nÊrmest fjernet alt "slÞr" der gjorde at computeren og kontrolleren af og til endte ud af sync - jeg kan dog godt nogle gange ramme 0% stoppet pÄ kontrolleren, hvor den reelle volume stÄr pÄ 2% og en sjÊlden gang 4%.. men sÄ er vi lidt i marginalerne.

Men stadig.. nÄr nu jeg selv havde sat succeskriterierne, og selv ville have et 0% stop og et 100% stop, sÄ var den eneste rigtige lÞsning i min bog, at fÄ det gjort - jeg ville kunne sÊtte en specifik vÊrdi, sÄledes at nÄr jeg pÄ den endelige enhed havde skruet 50% op, at volumen pÄ min computer ogsÄ var 50%!

SÄ ud over at lade kontrolleren klare nogle keystrokes den ene vej, eller den anden, mÄtte jeg finde en mÄde at faktisk interagere med selve styresystemet.

Kommandoen jeg skal kÞre for at sÊtte min volume til eks 50% kunne eks. se sÄledes ud:

pactl set-sink-volume @DEFAULT_SINK@ 50%

Det er noget jeg snildt ville kunne gÞre med python og dets indbyggede os-bibliotek. Jeg ville egentlig ogsÄ fra start af helst slippe for at have det dér "server/client" forhold, hvor der skulle kÞre noget som helst software pÄ computeren for at det virkede. Men jeg ville samtidig opnÄ prÊcissionen jeg havde sat mig for, og mÄtte derfor gÄ pÄ kompromis ét eller andet sted.

Jeg endte derfor ogsÄ med at lave et python "companion" script, der nÄr en enhed tilsluttes /dev/ttyACM0 starter af sig selv og overvÄger data der sendes til serial porten via pythons serial-bibliotek, imens jeg selvfÞlgeligt fik pro micro'en til at outputte selve volume-vÊrdien vi ledte efter til serial-porten.

success Og sÄ var vi dér! Super responsiv, reagere prÊcist sÄ hurtigt som jeg skruer op eller ned, ligesom den fungerer bÄde med og uden det ekstra python software, for at holde det simpelt og universelt.

Det gÞr at jeg nu kan tilslutte det til en vilkÄrlig anden enhed, der understÞttet et USB tastatur og justere lydstyrken med en ret stor prÊcission, hvor jeg pÄ min arbejdscomputer har den prÊcise kontrol jeg satte mig for at opnÄ. Der skulle i python-land blot trÊkkes lidt fra og lÊgges lidt til nÄr jeg satte lydstyrken til en specifik vÊrdi, da kontrolleren i sig selv, jo fortsat sendte de keystrokes der gÞr at den kan kÞre selv.

Schematic + BOM

BOM:

  • Arduino Pro Micro
  • 50K potentiometer
  • Jumper wires

Loddet sammen sÄlede:

1719826498.png
1719826498.png
Jeg brugte Fritzing til mit meget avancerede og nÞdvendige tegning her, hvor jeg ikke lige kunne finde en Arduino Pro Micro i deres dimse-bibliotek, men teensy'en er basicly samme formfaktor, og virker fint til formÄlet - alt man behÞves at vide er, at jeg sluttede midterste pin pÄ pot'en til A0 - Analog GPIO 0, og 3.3v til venstre ben og GND det hÞjre nÄr man ser potentiometeret ovenfra, som pÄ billedet ovenfor, med benene peget imod dig selv. 1719827074

C++ kode

Jeg koder jo mine embedded projekter/hardware projekter i PlatformIO (pio), som krÊver filene er c++ (.cpp) filer, i stedet arduino formatet (.ino). Arduino er jo et subset af C++ i forvejen, der blot tilfÞjer noget ekstra funktionalitet, sÄ koden herunder skulle vist meget gerne vÊre 100% kompatibel med Arduino IDE hvis du bruger dét, og blot Êndrer endelsen. Du skal ogsÄ installere HID-Project af NicoHood fra Arduino library-manageren.

#include <HID-Project.h>
#include <HID-Settings.h>

#define REVERSED true

int currentVolume = 0;
int previousVolume = 0;
int volumeAdjustment = 0;

void setup()
{
  Serial.begin(115200);
  Consumer.begin();
  delay(1000);
  for (int i = 0; i < 52; i++)
  {
    Consumer.write(MEDIA_VOLUME_DOWN);
    delay(2);
  }
}

void loop()
{
  currentVolume = analogRead(A0);
  currentVolume = map(currentVolume, 0, 1023, 0, 101);
  if (REVERSED)
  {
    currentVolume = 101 - currentVolume;
  }
  if (abs(currentVolume - previousVolume) > 1)
  {
    int volumeDifference = (currentVolume / 2) - (previousVolume / 2);
    previousVolume = currentVolume;

    if (volumeDifference > 0)
    {
      for (int i = 0; i < volumeDifference; i++)
      {
        Consumer.write(MEDIA_VOLUME_UP);
        Serial.println(volumeAdjustment + i + 1);
        delay(2);
      }
    }
    else if (volumeDifference < 0)
    {
      for (int i = 0; i < abs(volumeDifference); i++)
      {
        Consumer.write(MEDIA_VOLUME_DOWN);
        Serial.println(volumeAdjustment - i - 1);
        delay(2);
      }
    }
    volumeAdjustment += volumeDifference;
  }
  delay(35);
}

Python "companion script" (valgfri)

Ja, som nĂŠvnt er companion-scriptet her ikke overhovedet nogen nĂždvendighed, for at styre sin volume, men Ăžnsker du den 100% prĂŠcise kontrol kĂžrer du blot filen her.

import serial
import os
import time

port = '/dev/volume-knob' if os.path.exists('/dev/volume-knob') else '/dev/ttyACM0'
baudrate = 115200

def initialize_serial_connection(port, baudrate):
  try:
    connection = serial.Serial(port, baudrate, timeout=1)
    return connection
  except (serial.SerialException, OSError) as e:
    print(f"Failed to connect to {port}: {e}")
    exit(1)

def read_line_from_serial(connection):
  try:
    line = connection.readline().strip().decode('utf-8')
    return line
  except serial.SerialException:
    print("SerialException: Device disconnected")
    connection.close()
    exit(0)
  except OSError as e:
    print(f"OSError: {e}")
    connection.close()
    exit(1)

def main():
  connection = initialize_serial_connection(port, baudrate)
  previous_volume = None

  while True:
    line = read_line_from_serial(connection)

    if not line:
      time.sleep(0.1)
      continue

    try:
      volume = int(line) * 2
      print(volume)

      if previous_volume is not None:
        if volume > previous_volume:
          volume -= 2
        elif volume < previous_volume:
          volume += 2

      if previous_volume != volume:
        os.system(f'pactl set-sink-volume @DEFAULT_SINK@ {volume}%')
        previous_volume = volume

    except ValueError:
      print("Error: Invalid integer received")
      continue

if __name__ == "__main__":
    main()

Bruger din enhed en anden port end /dev/ttyACM0 Êndrer du selvfÞlgeligt blot port variablen til at vÊre dén dit system har tildelt.

Jeg angiver port som /dev/volume-knob da jeg i nÊste trin fÄr udev til at lave et symlink til dén path, og eksisterer den ikke defaulter scriptet til /dev/ttyACM0, sÄledes at det virker med og uden udev.
I projektets Github repo er der ogsÄ en debugging udgave af scriptet, der logger hvad der sker, hvis der er noget der driller.

KÞr kun nÄr enheden er tilsluttet

Som nÊvnt kÞrer jeg udelukkende scriptet, nÄr enheden er tilsluttet - de smÄ 50 LOCs er nok ikke noget man pÄ noget som helst tidspunkt ville bemÊrke kÞrte i baggrunden, hvis man blot kaldte scriptet ved boot, men jeg ser ingen grund til det - heller ikke selvom at enheden kommer til at vÊre tilsluttet min computer fra boot, hver gang den starter. Her bruger jeg udev sammen med systemd, som selvfÞlgeligt gÞr det til en *nix-only mÄde at gribe det an pÄ

Opret udev regel for dit board

  1. Brug lsusb kommandoen til at finde din enheds idVendor og idProduct og noter begge dele. F(Fire-cifrede tal, mine var hhv. 2341 og 8036)
  2. Opret en udev-regel for din enhed, sÄledes
sudo vim /etc/udev/rules.d/99-arduino-leonardo.rules
  1. Og tilfÞj fÞlgende linje, hvor du selvfÞlgeligt udskifter mit idVendor og idProduct, sÄ det matcher dit, ligesom du skal Êndre DIT-BRUGERNAVN og /PATH/TIL/DIT/VOLUME/COMPANION/SCRIPT.py til at matche din bruger og lokationen af dit script - det er vigtigt det er en komplet path - ikke noget ~/mappe/script.py, $HOME/mappe/script.py eller lignende! .
SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", ATTRS{idProduct}=="8036", SYMLINK+="volume-knob", RUN+="/bin/su DIT-BRUGERNAVN -c '/usr/bin/python /PATH/TIL/DIT/VOLUME/COMPANION/SCRIPT.py'"

Her ĂŠndrer du selvfĂžlgeligt DIT-BRUGERNAVN og /PATH/TIL/DIT/VOLUME/COMPANION/SCRIPT.py til at matche din bruger og lokationen af dit script - det er vigtigt det er en komplet path - ikke noget ~/mappe/script.py, $HOME/mappe/script.py eller lignende!

  1. Gem og luk (:wq)

GenindlĂŠs nu udev

  1. udev kan man fÄ til at genindlÊse sine regler med fÞlgende kommandoer:
sudo udevadm control --reload-rules
sudo udevadm trigger
  1. Tilslut kontrolleren - BOOM, nu skulle dit script gerne kÞre, sÄ vÊr OBS pÄ om nu volumen stÄr pÄ 100%, hvis du samtidigt lytter til et musik e.l!

SÄ er OS-delen af dit set ogsÄ fÊrdig - der findes andre mÄder at gribe det an pÄ, og nedenfor gÄr jeg ogsÄ lidt igennem, hvorfor jeg valgte at gÄ den vej som jeg gjorde.

Konklussion/How did it go?

Well... jeg har skrevet i beskrivelsen/meta tags til indlĂŠgget her at det er et "10 minutters projekt" - og hvor det er 100% rigtigt med denne lille "how-to", var det meget langt fra 10 minutter for mig.

IsÊr dét med at kÞre scriptet automatisk ved tilslutning noget der tog mig noget tid at komme igennem - de enheder jeg typisk laver, nÄr jeg tinker med hardware er ikke tiltÊnkt at skulle sluttes til en computer efterfÞlgende og er ofte selvstÊndige enheder.

Men jeg vidste at udev har med tilslutning af enheder at gÞre, efter jeg fÞrste gang satte PlatformIO op, sÄ i fÞrste ville jeg bruge udev til at kÞre en systemd service - det gik egentlig fint, men sÄ var der en udfordring i at pactl (pÄ mit system, i hvert fald) ikke kan styres af root-brugeren som systemd-services typisk bruger, da det er min bruger der kalder det ved login.

Yderligere havde jeg en udfordring at fÄ servicen til at stoppe igen, pÄ en mÄde sÄ den ikke bare stod som inactive/dead, selv nÊste gang jeg tilsluttede enheden igen. Dette brugte jeg sÄ noget tid pÄ, at prÞve at fikse i selve python scriptet, men jeg kunne ikke finde nogen gylden mellemvej, hvor scriptet sÄ fungerede som forventet bÄde ved manuel eksekvering og via udev/systemd ved tilslutning.

SÄ jeg droppede systemd og ville blot eksekvere scriptet nÄr enheden tilsluttes direkte i udev-reglen, som endte med, men her bÞvlede jeg sÄ rigtig meget med, at pÄ forskellige mÄder at igen eksekvere med min bruger, frem for root udev, akkurat som systemd er en systemprocess, og det er derfor normen at bruge root.

Men det er derfor at kommandoen der kÞres via udev er lidt f'd at se pÄ - typisk nÄr man kalder et python script, kan det klares med et simpelt kald:

python her/er/mit/script.py

NÄr systemet skal eksekvere det, skal den dog bruge en 100% lokation for bÄde programmet vi kalder og relative paths gÄr heller ikke, og vi ender i stedet pÄ noget a la:

/usr/bin/python /home/bruger/scripts/her/er/mit/script.py

Da vi oven i hatten skulle have systemet til at eksekvere pÄ brugerens vejne mÄtte jeg fÞrst bruge su, som pÄ *nix systemer bruges til at skifte bruger med, sammen med -u flaget til at definere hvem man eksekvere det som, efterfulgt af -c for at fortÊlle hvilken kommando der skal kÞres, og ender derfor med det her rod.

/bin/su -u mr -c /usr/bin/python /home/mr/Repos/volume-server/server.py

Og for at, som jeg jo altid sigter efter at gĂžre; citere Tom Hanks i rollen som Forest Gump: "And that all I have to say about that (đŸ€ź)

Men ift selve enheden, sÄ er jeg rigtig glad for resultatet! OgsÄ selvom at jeg pÄ AKKO Alice Pro tastaturet som jeg skriver pÄ lige nu, reelt set har dedikerede volume taster som gÞr AKKOrat (hÞhÞ) det samme. Jeg skifter dog ofte keyboards med forskellige layouts og antal af taster, sÄ at have den fysiske mulighed altid tilgÊngelig er fantastisk!

Og selvfÞlgelkigt - her er da ogsÄ lige et billede af, hvordan den ser ud:

volume knob
Som altid, nĂ„r jeg laver sĂ„danne projekter kigger jeg rundt pĂ„ mit kontor og tĂŠnker "hvad kan jeg proppe dig ind i? đŸ€”", og fandt en gammel Virginia Flake pibe tobak dĂ„se, jeg borede et par huller i til potentiometeret og pro micro'ens USB kabel

Hvis du tjekker linket nedenfor "USB Volume Controller - Potentiometer Based", vil du ogsÄ kunne en Instructables how-to, der indeholder 3D print/stl filer til et lignende projekt, der indeholde de samme komponenter.

Links og dokumentation

Det her har vÊret noget mere et trial and error-projekt, end sÄ meget andet, men her er dokumentationen til nogle af de ting der fik mig i mÄl, samt links til hvor man kan kÞbe hvad der skal bruges.

Links:

  • Fritzing - Schematics og hardware illustrationer
  • PlatformIO - Embedded programmering i din yndlings editor
  • Arduino - Open source prototype platform med egen IDE

Hardware:

Dokumentation

Der er ikke tale om affiliate links, det er blot for at gĂžre projektet nemmere at komme i gang med. For your tinkering pleasure!

@mikkelrask
ComputernĂžrden. Hobby futurist, linux entusiast, hardware hacker, tinkerer og generelt kreativt legebarn. Bosat i KĂžbenhavns Nordvest kvarter med min hund Homie. Jeg har arbejdet med computere hele mit liv, og ser en deres kunnen som en naturlig udvidelse af min egen.
© mr@github:~$ █, Built with Gatsby and hosted on Github.