Peripheral volume display (#411)

Adds the volume display effect to these boards that have LED Ring, when controlling the volume for LVA:

- Satellite 1 HAT Board

- ReSpeaker 4-Mic Array HAT

- ReSpeaker USB Mic Array v2.0

This animation appears on the LED ring temporarily to display an arc that sweeps clockwise when the volume increases and anti-clockwise when the volume decreases. Triggers by the volume_changed event.
This commit is contained in:
Omar Amin authored and GitHub committed 2026-08-23 01:31:37 +03:00
1 parent d1f5761f75
commit adcef575b7
6 files changed
+196 -43

No files matched your search

@@ -86,6 +86,14 @@ USB_PRODUCT_ID = 0x0018
LED_COUNT = 12
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
# Volume Display effect
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda. Origin
# is LED 9 (bottom, DOA 270°) — the same reference LED already used for
# the volume_muted indicator (VOLUME_MUTED_LED_INDEX), sitting between the
# micro-USB connector and the 3.5 mm AUX jack — so the arc grows from the
# same spot that turns solid red when volume is silenced.
VOLUME_DISPLAY_SECONDS = 2.5
# LED that lights up solid red while the media player (speaker) output is
# muted at volume 0 — the `volume_muted` peripheral event. LED 9 is the
# board's bottom-middle position (DOA 270°), sitting right between the
@@ -134,6 +142,10 @@ class SharedState:
self.ha_connected: bool = False
self.muted: bool = False # mic mute
self.volume: float = 1.0
# Monotonic deadline until which the Volume Display arc takes over
# the ring, set on each volume_changed event. 0 (or in the past)
# means the arc is not showing.
self.volume_display_until: float = 0.0
self.volume_muted: bool = False # media player volume zero
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
@@ -160,6 +172,7 @@ class SharedState:
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"volume_display_until": self.volume_display_until,
"volume_muted": self.volume_muted,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
@@ -417,6 +430,32 @@ class LEDRing:
self._leds.set(6, _scale(RED, factor))
self._leds.set(9, _scale(RED, factor))
def _anim_volume_display(self, color: RGB, volume: float) -> float:
"""
Arc showing the current volume level, originating at LED 9
(bottom, between the micro-USB and AUX jack — same LED as
VOLUME_MUTED_LED_INDEX) and sweeping clockwise around the ring.
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda —
proportional arc with a partial-brightness leading LED, plus a red
indicator on the origin LED when volume is silenced.
"""
silenced_color = RED
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
for i in range(LED_COUNT):
if i <= volume_ratio:
brightness = min(volume_ratio - i, 1.0)
self._leds.set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, _scale(color, brightness))
else:
self._leds.set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, BLACK)
if volume <= 0.0:
self._leds.set(VOLUME_MUTED_LED_INDEX, silenced_color)
self._leds.show()
return 0.05 # matches the 50ms update_interval of the ESPHome effect
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red dot on the bottom-middle LED (index
@@ -585,7 +624,12 @@ class LEDRing:
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
if anim == self.ANIM_OFF:
# Volume Display takes over the ring temporarily, regardless of
# the current pipeline state, then falls back to the normal
# animation once its deadline passes.
if snap["volume_display_until"] > time.monotonic():
sleep = self._anim_volume_display(color, snap["volume"])
elif anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
@@ -810,7 +854,10 @@ class LVAClient:
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
self._state.update(
volume=data.get("volume", 1.0),
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
)
elif event == "volume_muted":
Volume_muted = data.get("muted", True)
@@ -818,7 +865,7 @@ class LVAClient:
if Volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
self._state.update(assist_state=AssistState.IDLE)
elif event == "zeroconf":
status = data.get("status", "")