feat: WebSocket команды — раздельная громкость mic/snd + запись аудио + обновлённый web-client

- satellite.py: разделён _runtime_volume на _runtime_mic_volume и _runtime_snd_volume
- satellite.py: добавлены команды start_recording/stop_recording (WAV через base64)
- satellite.py: метод _create_wav_from_buffer() для создания WAV из буфера
- web-client: добавлен слайдер громкости микрофона (MIC+/MIC-)
- web-client: добавлены кнопки записи и воспроизведения аудио
- web-client: улучшенный лог событий и отладочные console.log
- docs: websocket-server-plan.md сокращён с 1205 до ~80 строк

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-05-25 17:58:37 +03:00
co-authored by Qwen-Coder
parent 61294485ad
commit 2b4e6f824b
7 changed files with 2017 additions and 1209 deletions
+105 -14
View File
@@ -113,7 +113,8 @@ class SatelliteBase:
self._unmute_microphone_task: Optional[asyncio.Task] = None
# Runtime настройки (изменяемые через WebSocket команды)
self._runtime_volume: float = 1.0
self._runtime_mic_volume: float = 1.0 # Громкость микрофона
self._runtime_snd_volume: float = 1.0 # Громкость воспроизведения
self._runtime_led_brightness: int = 128
# WebSocket server
@@ -125,6 +126,11 @@ class SatelliteBase:
self._last_mic_level: float = 0.0
self._audio_level_task: Optional[asyncio.Task] = None
# Audio recording
self._is_recording: bool = False
self._recording_buffer: List[bytes] = []
self._recording_start_time: Optional[float] = None
# Debug audio recording
self.wake_audio_writer: Optional[DebugAudioWriter] = None
self.stt_audio_writer: Optional[DebugAudioWriter] = None
@@ -591,9 +597,9 @@ class SatelliteBase:
audio_bytes, self.settings.mic.volume_multiplier
)
# Применить runtime громкость (из WebSocket команд)
if self._runtime_volume != 1.0:
audio_bytes = multiply_volume(audio_bytes, self._runtime_volume)
# Применить runtime громкость микрофона (из WebSocket команд)
if self._runtime_mic_volume != 1.0:
audio_bytes = multiply_volume(audio_bytes, self._runtime_mic_volume)
if self._mic_webrtc is not None:
# Noise suppression and auto gain
@@ -653,9 +659,13 @@ class SatelliteBase:
_LOGGER.debug("Connected to snd service")
# Audio processing
if self.settings.snd.needs_processing and AudioChunk.is_type(
event.type
):
# Проверяем нужна ли обработка: базовый volume_multiplier или runtime громкость
needs_processing = (
self.settings.snd.needs_processing # volume_multiplier != 1.0
or self._runtime_snd_volume != 1.0 # WebSocket громкость
)
if needs_processing and AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
audio_bytes = self._process_snd_audio(chunk.audio)
event = AudioChunk(
@@ -693,6 +703,11 @@ class SatelliteBase:
audio_bytes, self.settings.snd.volume_multiplier
)
# Применить runtime громкость воспроизведения (из WebSocket команд)
if self._runtime_snd_volume != 1.0:
_LOGGER.debug(f"Applying runtime volume: {self._runtime_snd_volume}")
audio_bytes = multiply_volume(audio_bytes, self._runtime_snd_volume)
return audio_bytes
async def _play_wav(
@@ -1149,7 +1164,8 @@ class SatelliteBase:
Обработать команду от WebSocket клиента.
Поддерживаемые команды:
- set_volume: Изменить громкость
- set_volume: Изменить громкость воспроизведения (TTS, звуки)
- set_mic_volume: Изменить чувствительность микрофона
- mute: Заглушить микрофон
- unmute: Включить микрофон
- play_sound: Воспроизвести WAV файл
@@ -1158,12 +1174,19 @@ class SatelliteBase:
"""
try:
if command == "set_volume":
# Изменить громкость (0.0 - 1.0)
# Изменить громкость воспроизведения (0.0 - 1.0)
# Поддерживаем оба названия: "level" и "volume" (для обратной совместимости)
level = float(data.get("level") or data.get("volume", 1.0))
level = max(0.0, min(1.0, level)) # Clamp 0-1
self._runtime_volume = level
_LOGGER.info(f"Setting volume to {level}")
self._runtime_snd_volume = level
_LOGGER.info(f"Setting playback volume to {level}")
elif command == "set_mic_volume":
# Изменить чувствительность микрофона (0.0 - 2.0)
level = float(data.get("level") or data.get("volume", 1.0))
level = max(0.0, min(2.0, level)) # Clamp 0-2
self._runtime_mic_volume = level
_LOGGER.info(f"Setting microphone sensitivity to {level}")
elif command == "mute":
_LOGGER.info("Muting microphone")
@@ -1189,6 +1212,42 @@ class SatelliteBase:
self._runtime_led_brightness = brightness
_LOGGER.info(f"Setting LED brightness to {brightness}")
elif command == "start_recording":
# Начать запись аудио с микрофона
_LOGGER.info("Starting audio recording")
self._is_recording = True
self._recording_buffer = []
self._recording_start_time = time.time()
elif command == "stop_recording":
# Остановить запись и вернуть WAV файл
if not self._is_recording:
_LOGGER.warning("Recording not started")
return
_LOGGER.info("Stopping audio recording")
self._is_recording = False
# Создать WAV файл из буфера
duration = time.time() - (self._recording_start_time or 0)
wav_data = await self._create_wav_from_buffer()
# Отправить событие с данными записи
import base64
recording_event = Event(
type="recording-ready",
data={
"duration": duration,
"size": len(wav_data),
"data": base64.b64encode(wav_data).decode('utf-8')
}
)
await self._broadcast_to_websocket(recording_event)
# Очистить буфер
self._recording_buffer = []
_LOGGER.info(f"Recording saved: {duration:.1f}s, {len(wav_data)} bytes")
elif command == "trigger_event":
# Отправить событие в Home Assistant
event_type = data.get("event_type")
@@ -1196,9 +1255,8 @@ class SatelliteBase:
if event_type:
_LOGGER.info(f"Triggering event: {event_type}")
# Отправить событие в server
from wyoming.event import Event
event = Event(type=event_type, data=event_data)
await self.event_to_server(event)
trigger_event = Event(type=event_type, data=event_data)
await self.event_to_server(trigger_event)
else:
_LOGGER.warning("trigger_event missing 'event_type'")
@@ -1250,6 +1308,35 @@ class SatelliteBase:
if self._last_mic_level > 0.05:
_LOGGER.debug(f"Audio level: {self._last_mic_level:.2f}")
# -------------------------------------------------------------------------
# Audio Recording
# -------------------------------------------------------------------------
async def _create_wav_from_buffer(self) -> bytes:
"""
Создать WAV файл из буфера записи.
Returns:
bytes: WAV файл в виде байтов
"""
import io
if not self._recording_buffer:
return b''
# Объединить все чанки
audio_data = b''.join(self._recording_buffer)
# Создать WAV файл в памяти
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(self.settings.mic.channels)
wav_file.setsampwidth(self.settings.mic.width)
wav_file.setframerate(self.settings.mic.rate)
wav_file.writeframes(audio_data)
return wav_buffer.getvalue()
# -------------------------------------------------------------------------
# Info
# -------------------------------------------------------------------------
@@ -1619,6 +1706,10 @@ class WakeStreamingSatellite(SatelliteBase):
self._last_mic_level = self._calculate_rms(audio_bytes)
# Сохранить в буфер записи (если запись активна)
if self._is_recording:
self._recording_buffer.append(audio_bytes)
# Debug audio recording
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
if self.wake_audio_writer is not None: