fix: HA light target — brightness source, transition=0, dashboard type label
Lint & Test / test (push) Successful in 1m13s
Lint & Test / test (push) Successful in 1m13s
- Add brightness_value_source_id to HALightOutputTarget model, to_dict,
from_dict, update_fields, register_with_manager, API response
- Wire value stream in HALightTargetProcessor: acquire/release on
start/stop, multiply brightness in _update_lights loop
- Fix transition=0 not saving (parseFloat("0") || 0.5 was falsy)
- Fix dashboard showing "Key Colors" for HA targets — now "Home Assistant"
- Fix dashboard FPS showing 0/2 — HA targets show target/target
- Add CSS source subtitle to HA target dashboard cards
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
"""Cross-platform asynchronous sound playback for notification alerts.
|
||||
|
||||
Windows: uses winsound.PlaySound (stdlib, no dependencies).
|
||||
Linux: uses paplay (PulseAudio) or aplay (ALSA) via subprocess.
|
||||
Uses just_playback (backed by miniaudio) for MP3/WAV/OGG/FLAC playback
|
||||
with native volume control on all platforms.
|
||||
|
||||
All playback is fire-and-forget on a background thread. A new notification
|
||||
sound cancels any currently playing sound to prevent overlap.
|
||||
A new notification sound cancels any currently playing sound to prevent overlap.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,133 +13,29 @@ from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Lock + handle for cancelling previous sound
|
||||
_play_lock = threading.Lock()
|
||||
_current_process: subprocess.Popen | None = None
|
||||
# Hold reference to SND_MEMORY buffer to prevent GC during async playback
|
||||
_win_sound_buf: bytes | None = None
|
||||
_lock = threading.Lock()
|
||||
_playback = None # lazy-init on first use
|
||||
|
||||
|
||||
def _scale_wav_volume(file_path: Path, volume: float) -> bytes | None:
|
||||
"""Read a WAV file and return a volume-scaled WAV as bytes.
|
||||
def _get_playback():
|
||||
"""Lazy-init the Playback singleton (import is heavy, defer until needed)."""
|
||||
global _playback
|
||||
if _playback is None:
|
||||
from just_playback import Playback
|
||||
|
||||
Uses stdlib wave + struct to scale PCM samples in memory.
|
||||
Returns None on error or if the format is unsupported.
|
||||
"""
|
||||
import io
|
||||
import struct
|
||||
import wave
|
||||
|
||||
try:
|
||||
with wave.open(str(file_path), "rb") as wf:
|
||||
n_channels = wf.getnchannels()
|
||||
sample_width = wf.getsampwidth()
|
||||
framerate = wf.getframerate()
|
||||
n_frames = wf.getnframes()
|
||||
raw = wf.readframes(n_frames)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read WAV for volume scaling: {e}")
|
||||
return None
|
||||
|
||||
if sample_width not in (1, 2):
|
||||
return None # Only 8-bit and 16-bit PCM supported
|
||||
|
||||
# Scale samples
|
||||
if sample_width == 2:
|
||||
fmt = f"<{len(raw) // 2}h"
|
||||
samples = struct.unpack(fmt, raw)
|
||||
scaled = struct.pack(fmt, *(max(-32768, min(32767, int(s * volume))) for s in samples))
|
||||
else:
|
||||
# 8-bit WAV is unsigned, center at 128
|
||||
samples = struct.unpack(f"{len(raw)}B", raw)
|
||||
scaled = struct.pack(
|
||||
f"{len(raw)}B",
|
||||
*(max(0, min(255, int((s - 128) * volume + 128))) for s in samples),
|
||||
)
|
||||
|
||||
# Write scaled WAV to memory buffer
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as out:
|
||||
out.setnchannels(n_channels)
|
||||
out.setsampwidth(sample_width)
|
||||
out.setframerate(framerate)
|
||||
out.writeframes(scaled)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _play_windows(file_path: Path, volume: float) -> None:
|
||||
"""Play a WAV file on Windows using winsound with volume scaling."""
|
||||
import winsound
|
||||
|
||||
global _win_sound_buf
|
||||
|
||||
try:
|
||||
if volume < 1.0:
|
||||
wav_data = _scale_wav_volume(file_path, volume)
|
||||
if wav_data:
|
||||
# Keep a global reference so GC doesn't free the buffer
|
||||
# while async playback is still using it
|
||||
_win_sound_buf = wav_data
|
||||
winsound.PlaySound(wav_data, winsound.SND_MEMORY | winsound.SND_ASYNC)
|
||||
return
|
||||
# Full volume or fallback: play file directly
|
||||
_win_sound_buf = None
|
||||
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
|
||||
except Exception as e:
|
||||
logger.error(f"winsound playback failed: {e}")
|
||||
|
||||
|
||||
def _play_linux(file_path: Path, volume: float) -> None:
|
||||
"""Play a sound file on Linux using paplay or aplay."""
|
||||
global _current_process
|
||||
|
||||
# Cancel previous sound
|
||||
with _play_lock:
|
||||
if _current_process is not None:
|
||||
try:
|
||||
_current_process.terminate()
|
||||
except OSError as e:
|
||||
logger.debug("Failed to terminate previous sound process: %s", e)
|
||||
pass
|
||||
_current_process = None
|
||||
|
||||
try:
|
||||
# Try paplay first (PulseAudio/PipeWire) — supports volume
|
||||
pa_volume = max(0, min(65536, int(volume * 65536)))
|
||||
proc = subprocess.Popen(
|
||||
["paplay", f"--volume={pa_volume}", str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
# Fallback to aplay (ALSA) — no volume control
|
||||
proc = subprocess.Popen(
|
||||
["aplay", "-q", str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Neither paplay nor aplay found — cannot play notification sound")
|
||||
return
|
||||
|
||||
with _play_lock:
|
||||
_current_process = proc
|
||||
|
||||
# Wait for completion
|
||||
proc.wait()
|
||||
|
||||
with _play_lock:
|
||||
if _current_process is proc:
|
||||
_current_process = None
|
||||
_playback = Playback()
|
||||
return _playback
|
||||
|
||||
|
||||
def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
|
||||
"""Play a sound file asynchronously (fire-and-forget).
|
||||
"""Play a sound file (non-blocking).
|
||||
|
||||
Supports WAV, MP3, OGG, FLAC. A new call stops any currently playing sound.
|
||||
just_playback.play() is inherently non-blocking (miniaudio backend thread).
|
||||
|
||||
Args:
|
||||
file_path: Path to the sound file (.wav).
|
||||
volume: Volume level 0.0-1.0 (best-effort, not all backends support it).
|
||||
file_path: Path to the sound file.
|
||||
volume: Volume level 0.0-1.0.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Sound file not found: {file_path}")
|
||||
@@ -150,37 +43,23 @@ def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
|
||||
|
||||
volume = max(0.0, min(1.0, volume))
|
||||
|
||||
if sys.platform == "win32":
|
||||
player = _play_windows
|
||||
else:
|
||||
player = _play_linux
|
||||
|
||||
thread = threading.Thread(
|
||||
target=player,
|
||||
args=(file_path, volume),
|
||||
name="sound-player",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
with _lock:
|
||||
try:
|
||||
pb = _get_playback()
|
||||
if pb.active:
|
||||
pb.stop()
|
||||
pb.load_file(str(file_path))
|
||||
pb.set_volume(volume)
|
||||
pb.play()
|
||||
except Exception as e:
|
||||
logger.error(f"Sound playback failed: {e}")
|
||||
|
||||
|
||||
def stop_current_sound() -> None:
|
||||
"""Stop any currently playing notification sound."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winsound
|
||||
|
||||
winsound.PlaySound(None, winsound.SND_PURGE)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to stop winsound playback: %s", e)
|
||||
pass
|
||||
else:
|
||||
with _play_lock:
|
||||
global _current_process
|
||||
if _current_process is not None:
|
||||
try:
|
||||
_current_process.terminate()
|
||||
except OSError as e:
|
||||
logger.debug("Failed to terminate sound process: %s", e)
|
||||
pass
|
||||
_current_process = None
|
||||
with _lock:
|
||||
if _playback is not None and _playback.active:
|
||||
try:
|
||||
_playback.stop()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to stop playback: %s", e)
|
||||
|
||||
Reference in New Issue
Block a user