Drop the brightness control, survive and explain a frozen mic
- Remove RGB brightness: firmware 2.1.2 stores the value but the LEDs never change, so the control did nothing. PROTOCOL.md notes why. - Lock the device per transaction (flock) so the CLI and the web service can't interleave and steal each other's replies. - Detect a mic that is plugged in but no longer answering and tell the user to replug it (a USB reset is not enough; settings survive). Seen when hardware buttons are held while settings are being written. - Web page keeps the last known value when a poll comes back partial, instead of drawing unanswered settings as off. - New screenshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -55,7 +55,7 @@ The rest of the 64 bytes is zero-padded.
|
||||
| 0x2026 | headphone monitoring | 2000 = on, 3000 = off |
|
||||
| 0x2027 | noise cancellation | 0 / 1 |
|
||||
| 0x2028 | noise cancellation strength | 0-100 |
|
||||
| 0x202A | RGB ring brightness | 0-100 (the app does not show it for this model) |
|
||||
| 0x202A | labelled RGB ring brightness in the app's shared code | stores 0-100, but the LEDs do not change on firmware 2.1.2; not exposed |
|
||||
| 0x202C | vocal enhancement | 0 / 1 |
|
||||
| 0x204B | level-meter enable | not answered by firmware 2.1.2 |
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ this covers the same settings with a local web page and a command-line tool.
|
||||
- Vocal enhancement
|
||||
- Hear yourself (headphone monitoring)
|
||||
- Mute, kept in sync with the mic's touch pad
|
||||
- RGB ring brightness
|
||||
- Factory reset
|
||||
|
||||
Pure Python, no dependencies. Nothing leaves your machine: the page is served from your own
|
||||
@@ -80,7 +79,7 @@ keeps its current settings.
|
||||
dgm20-web --open # open the control page (http://localhost:8720)
|
||||
dgm20ctl # show current settings
|
||||
dgm20ctl set nc on nc-level 80
|
||||
dgm20ctl set monitor off enhance on brightness 40
|
||||
dgm20ctl set monitor off enhance on
|
||||
dgm20ctl watch # print changes made on the mic itself
|
||||
dgm20ctl info # model and firmware
|
||||
dgm20ctl factory-reset --yes
|
||||
@@ -93,7 +92,6 @@ dgm20ctl factory-reset --yes
|
||||
| `nc-level` | 0-100: noise cancellation strength |
|
||||
| `enhance` | on, off: vocal enhancement |
|
||||
| `monitor` | on, off: hear yourself in headphones plugged into the mic |
|
||||
| `brightness` | 0-100: RGB ring |
|
||||
|
||||
Settings are stored on the mic, so they stay the same after a replug and on other computers.
|
||||
|
||||
@@ -124,6 +122,11 @@ model that isn't supported yet (see above).
|
||||
**`dgm20-web: cannot listen on 127.0.0.1:8720`**: it's already running, for example as the
|
||||
service. Open http://localhost:8720, or pick another port with `--port`.
|
||||
|
||||
**"the mic is connected but has stopped answering"**: the mic's settings channel froze. This can
|
||||
happen if you hold or tap the mic's buttons while settings are being changed from the computer.
|
||||
Unplug the mic and plug it back in; its settings are kept. A software USB reset is not enough,
|
||||
the mic needs to lose power.
|
||||
|
||||
**Commands not found after installing**: `~/.local/bin` isn't on your `PATH`. Add
|
||||
`export PATH="$HOME/.local/bin:$PATH"` to your shell profile.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 76 KiB |
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "dgm20-linux"
|
||||
version = "0.1.0"
|
||||
description = "Control the Maono DGM20 USB microphone on Linux: noise cancellation, monitoring, vocal enhancement, mute, RGB brightness"
|
||||
description = "Control the Maono DGM20 USB microphone on Linux: noise cancellation, monitoring, vocal enhancement, mute"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
+21
-2
@@ -20,6 +20,7 @@ Both share one framing:
|
||||
Settings are 16-bit registers. Input gain and headphone volume are standard
|
||||
USB audio controls and are not part of this channel.
|
||||
"""
|
||||
import fcntl
|
||||
import glob
|
||||
import os
|
||||
import select
|
||||
@@ -37,7 +38,6 @@ SETTINGS = {
|
||||
"nc-level": (0x2028, (0, 100), "Noise cancellation strength"),
|
||||
"enhance": (0x202C, "bool", "Vocal enhancement"),
|
||||
"monitor": (0x2026, {"on": 2000, "off": 3000}, "Hear yourself in headphones"),
|
||||
"brightness": (0x202A, (0, 100), "RGB ring brightness"),
|
||||
}
|
||||
REG_FACTORY_RESET = 0x100A
|
||||
REG_DFU = 0x1008
|
||||
@@ -48,6 +48,14 @@ class DeviceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotResponding(DeviceError):
|
||||
"""The mic is plugged in but its settings channel has stopped answering."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("the mic is connected but has stopped answering. Unplug it and plug it back in; "
|
||||
"your settings are kept")
|
||||
|
||||
|
||||
def find_hidraw():
|
||||
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||
try:
|
||||
@@ -152,8 +160,11 @@ class Mic:
|
||||
os.read(self.fd, 64)
|
||||
|
||||
def _io(self, typ, words, accept, timeout=0.5):
|
||||
# the flock makes other processes (CLI, web service) wait their turn, so nobody
|
||||
# mistakes another process's reply or ack for their own
|
||||
with self.lock:
|
||||
try:
|
||||
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
||||
self._drain()
|
||||
os.write(self.fd, frame(typ, words))
|
||||
while select.select([self.fd], [], [], timeout)[0]:
|
||||
@@ -162,6 +173,11 @@ class Mic:
|
||||
return p[1]
|
||||
except OSError as e:
|
||||
raise DeviceError(f"mic disconnected ({e.strerror})") from None
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def read(self, addr):
|
||||
@@ -188,7 +204,10 @@ class Mic:
|
||||
return self.get(name)
|
||||
|
||||
def state(self):
|
||||
return {name: self.get(name) for name in SETTINGS}
|
||||
state = {name: self.get(name) for name in SETTINGS}
|
||||
if all(v is None for v in state.values()):
|
||||
raise NotResponding()
|
||||
return state
|
||||
|
||||
def info(self):
|
||||
model = self.read_range(REG_MODEL, 8)
|
||||
|
||||
@@ -179,11 +179,6 @@ footer { margin-top: 28px; display: flex; justify-content: space-between; flex-w
|
||||
<p>Plays the mic through headphones plugged into the DGM20.</p>
|
||||
<button class="switch ctl" role="switch" aria-checked="false" aria-labelledby="l-monitor" data-name="monitor" disabled></button>
|
||||
</li>
|
||||
<li class="row slider">
|
||||
<label for="brightness">Ring brightness</label>
|
||||
<input type="range" id="brightness" min="0" max="100" step="1" data-name="brightness" disabled>
|
||||
<output for="brightness" id="brightness-out">–</output>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer>
|
||||
@@ -243,6 +238,9 @@ function render(snap) {
|
||||
if (/permission/i.test(err)) {
|
||||
notice("The mic is plugged in, but this page can't talk to it.",
|
||||
"Install the udev rule, then unplug and replug the mic:", "sudo cp udev/70-maono-dgm20.rules /etc/udev/rules.d/");
|
||||
} else if (/stopped answering/i.test(err)) {
|
||||
notice("The mic stopped answering.",
|
||||
"Unplug it and plug it back in. Your settings are kept, and this page reconnects on its own.");
|
||||
} else {
|
||||
notice("Mic not found.", "Plug in the DGM20. This page reconnects on its own.");
|
||||
}
|
||||
@@ -258,7 +256,7 @@ function render(snap) {
|
||||
$("fw").textContent = `Firmware ${snap.info.firmware}. Unofficial tool, not affiliated with Maono.`;
|
||||
}
|
||||
|
||||
if (!busy.has("mute")) {
|
||||
if (!busy.has("mute") && state.mute != null) {
|
||||
pad.setAttribute("aria-pressed", String(!!state.mute));
|
||||
pad.setAttribute("aria-label", state.mute ? "Unmute microphone" : "Mute microphone");
|
||||
$("pad-state").textContent = state.mute ? "Muted" : "Live";
|
||||
@@ -269,6 +267,7 @@ function render(snap) {
|
||||
const n = s.dataset.name;
|
||||
if (busy.has(n)) continue;
|
||||
const v = state[n];
|
||||
if (v == null) continue; // mic busy for a moment: keep the last known value
|
||||
s.setAttribute("aria-checked", String(v === true || v === "on"));
|
||||
}
|
||||
for (const s of sliders) {
|
||||
@@ -278,7 +277,6 @@ function render(snap) {
|
||||
$(`${n}-out`).textContent = state[n];
|
||||
}
|
||||
$("nc-level-row").dataset.disabled = state.nc ? "0" : "1";
|
||||
document.documentElement.style.setProperty("--ring-glow", ((state.brightness ?? 100) / 100 * 0.85 + 0.15).toFixed(2));
|
||||
lastConnected = true;
|
||||
}
|
||||
|
||||
@@ -314,7 +312,6 @@ for (const s of sliders) {
|
||||
s.addEventListener("input", () => {
|
||||
busy.add(n);
|
||||
$(`${n}-out`).textContent = s.value;
|
||||
if (n === "brightness") document.documentElement.style.setProperty("--ring-glow", (s.value / 100 * 0.85 + 0.15).toFixed(2));
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => api("/api/set", { name: n, value: Number(s.value) }).catch(() => {}), 120);
|
||||
});
|
||||
|
||||
@@ -60,5 +60,21 @@ class Encoding(unittest.TestCase):
|
||||
encode("volume", 3)
|
||||
|
||||
|
||||
|
||||
class NotRespondingDetection(unittest.TestCase):
|
||||
def test_all_unanswered_reads_raise(self):
|
||||
from dgm20.device import Mic, NotResponding
|
||||
mic = Mic.__new__(Mic) # no device needed: reads always time out
|
||||
mic.read = lambda addr: None
|
||||
with self.assertRaises(NotResponding):
|
||||
mic.state()
|
||||
|
||||
def test_partial_answers_are_fine(self):
|
||||
from dgm20.device import Mic
|
||||
mic = Mic.__new__(Mic)
|
||||
mic.read = lambda addr: 1 if addr == 0x2022 else None
|
||||
self.assertTrue(mic.state()["mute"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user