Энтузиаст Джеймс Воган поделился, что он приобрел несколько колонок со встроенными стриминговыми сервисами, но остался недоволен их системой регулировки громкости. Он решил настроить их так, чтобы получить более точный контроль в комфортном диапазоне воспроизведения.

Обычно Воган использует около 10% от диапазона громкости, на который способны колонки. Это затрудняет для него регулировку звука, так как крошечный ползунок можно использовать только на 10% или около 15 шагов, где переход от шага 3 к шагу 4 переводит динамики с уровня «немного тихо» на уровень «определённо беспокоит соседей».

Энтузиаст изучил недокументированные веб-интерфейсы динамиков, найдя их локальный IP-адрес через свой маршрутизатор.

Он обнаружил, что динамики предоставляют довольно простой HTTP API, включая GET/api/getData и POST/api/setData, которые позволяют читать и записывать текущий уровень громкости.

Затем Воган нашёл исходный код плагина Hombridge для динамиков KEF, которые, как и JBL, используют StreamSDK. Он обнаружил, что в веб-интерфейсе динамиков есть страница, позволяющая загружать системные журналы с копией части файловой системы, в которой хранятся текущие настройки. Это помогло отследить два конкретных пути конфигурации: player/attenuation и hostlink/maxVolume. 

$ curl --url 'http://192.168.1.239/api/getData?path=settings:/hostlink/maxVolume&roles=@all' | jq
{
  "timestamp": 1711309370908,
  "title": "Max volume setting (ARCAM-project specific)",
  "modifiable": true,
  "type": "value",
  "path": "settings:/hostlink/maxVolume",
  "defaultValue": {
    "type": "i32_",
    "i32_": 99
  },
  "value": {
    "type": "i32_",
    "i32_": 46
  }
}

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

Для его обслуживания он создал небольшой сервер с помощью Bun. Благодаря этому удалось ограничиться одним файлом TypeScript без каких-либо зависимостей, кроме самого Bun. Веб-сервер обслуживает страницу с ползунком и пересылает запросы на динамики.

// server.ts
// Run this via `bun --hot server.ts`

const MAX_VOLUME = 25;
const SPEAKER_URL = "http://192.168.1.239";
const UPDATE_INTERVAL_SECONDS = 10;

const getVolumeUrl = `${SPEAKER_URL}/api/getData?path=player:volume&roles=@all`;

function html(strings: TemplateStringsArray, ...values: any[]) {
  return strings.reduce((result, string, i) => {
    return result + string + (values[i] || "");
  }, "");
}

const pageHtml = html`<html>
  <head>
    <title>volume</title>
    <style>
      body {
        margin: 0;
      }

      #volume {
        -webkit-appearance: none;
        appearance: none;
        margin: 0;
        width: 100%;
        height: 100%;
        cursor: pointer;
        outline: none;
        background: linear-gradient(to right, blue var(--volume), white 0);
      }

      /* Hide the thumb */
      #volume::-webkit-slider-thumb {
        -webkit-appearance: none;
        appearance: none;
        width: 0;
        height: 0;
      }
      #volume::-moz-range-thumb {
        width: 0;
        height: 0;
      }
    </style>
  </head>
  <body>
    <input
      type="range"
      min="0"
      max="${MAX_VOLUME}"
      value="0"
      id="volume"
      disabled
    />
    <script>
      // Yes, this is JavaScript embedded in HTML embedded in TypeScript.

      function setBackgroundGradient() {
        const percentage = (volume.value / ${MAX_VOLUME}) * 100;
        document.body.style.setProperty("--volume", \`\${percentage}%\`);
      }

      // I only recently learned that you can reference elements by ID this way.
      // It's kind of horrible but also I love it on tiny pages like this.
      volume.oninput = async function setVolume() {
        fetch("volume", {
          method: "POST",
          body: JSON.stringify({ volume: volume.value }),
        });
        setBackgroundGradient();
      };

      async function getVolume() {
        const response = await fetch("volume");
        const body = await response.text();
        volume.value = body;
        volume.disabled = false;
        setBackgroundGradient();
      }

      getVolume();
      setInterval(getVolume, ${UPDATE_INTERVAL_SECONDS * 1000});
    </script>
  </body>
</html>`;

const server = Bun.serve({
  async fetch(request) {
    const url = new URL(request.url);

    switch (url.pathname) {
      case "/":
        return new Response(pageHtml, {
          headers: { "Content-Type": "text/html" },
        });

      case "/volume":
        switch (request.method) {
          case "GET": {
            const response = await fetch(getVolumeUrl);
            const body = await response.json();
            return new Response(body.value.i32_);
          }

          case "POST": {
            const { volume } = await request.json();

            console.log(`Setting volume to ${volume}.`);

            // I don't want to blow out the speakers or go deaf because of a bug
            // somewhere else in this code, so I check for high volumes here.
            if (volume > MAX_VOLUME) {
              console.error("That's too high!", volume);
              return new Response("Volume too high", { status: 500 });
            }

            return fetch(`${SPEAKER_URL}/api/setData`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                path: "player:volume",
                role: "value",
                value: { type: "i32_", i32_: volume },
                _nocache: new Date().getTime(),
              }),
            });
          }
        }
    }

    console.error("Not found:", url.pathname);
    return new Response("Not Found", { status: 404 });
  },
});

console.log(`Server running on http://localhost:${server.port}.`);

Теперь Воган намерен создать физическую ручку громкости с применением платы ESP32.