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