Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions linux_voice_assistant/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures

from .audio_sinks import pulse_sink_to_mpv_device
from .models import Preferences, ServerState
from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol
Expand Down Expand Up @@ -278,6 +279,30 @@ async def main() -> None:
with open(preferences_path, "r", encoding="utf-8") as preferences_file:
preferences_dict = json.load(preferences_file)
preferences = Preferences(**preferences_dict)

# Resolve audio output startup state.
#
# AUDIO_OUTPUT_DEVICE / --audio-output-device remains a hard startup override.
# If it is not set, use the HA-managed preference saved in preferences.json.
#
# ServerState.audio_output_sink must be initialized here so the ESPHome
# select entity shows the actual selected sink after restart instead of
# falling back to "default".
selected_audio_output_sink = None
effective_audio_output_device = args.audio_output_device

if args.audio_output_device:
if args.audio_output_device in ("default", "auto"):
effective_audio_output_device = None
selected_audio_output_sink = None
elif args.audio_output_device.startswith("pulse/"):
selected_audio_output_sink = args.audio_output_device.removeprefix("pulse/")
else:
selected_audio_output_sink = args.audio_output_device
elif preferences.audio_output_sink:
selected_audio_output_sink = preferences.audio_output_sink
effective_audio_output_device = pulse_sink_to_mpv_device(preferences.audio_output_sink)

else:
preferences = Preferences()

Expand Down Expand Up @@ -325,14 +350,15 @@ async def main() -> None:
wake_words=wake_models,
active_wake_words=active_wake_words,
stop_word=stop_model,
music_player=MpvMediaPlayer(device=args.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device),
music_player=MpvMediaPlayer(device=effective_audio_output_device),
tts_player=MpvMediaPlayer(device=effective_audio_output_device),
wakeup_sound=args.wakeup_sound,
timer_finished_sound=args.timer_finished_sound,
processing_sound=args.processing_sound,
mute_sound=args.mute_sound,
unmute_sound=args.unmute_sound,
preferences=preferences,
audio_output_sink=selected_audio_output_sink,
preferences_path=preferences_path,
refractory_seconds=args.refractory_seconds,
continue_conversation_delay=args.continue_conversation_delay,
Expand Down
105 changes: 105 additions & 0 deletions linux_voice_assistant/audio_sinks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from __future__ import annotations

import logging
import re
import subprocess

_LOGGER = logging.getLogger(__name__)


def list_pulse_sink_names() -> list[str]:
try:
result = subprocess.run(
["pactl", "list", "short", "sinks"],
check=True,
capture_output=True,
text=True,
timeout=3,
)
except FileNotFoundError:
_LOGGER.warning("pactl not found; cannot list PulseAudio/PipeWire sinks")
return []
except subprocess.SubprocessError:
_LOGGER.exception("Failed to list PulseAudio/PipeWire sinks")
return []

names: list[str] = []

for line in result.stdout.splitlines():
parts = line.split("\t")
if len(parts) < 2:
continue

name = parts[1].strip()
if name:
names.append(name)

return sorted(set(names))


def pulse_sink_to_mpv_device(sink_name: str | None) -> str | None:
if not sink_name or sink_name in ("default", "auto"):
return None

if sink_name.startswith("pulse/"):
return sink_name

return f"pulse/{sink_name}"


def _label_for_raop_sink(sink_name: str) -> str:
body = sink_name.removeprefix("raop_sink.")

match = re.search(r"\.local\.((?:\d{1,3}\.){3}\d{1,3})\.\d+$", body)
ip_addr = match.group(1) if match else None

if ".local." in body:
friendly = body.split(".local.", 1)[0]
else:
friendly = body

friendly = friendly.replace("-", " ")

if friendly.startswith("Sonos "):
friendly = "Sonos"

if ip_addr:
return f"{friendly} ({ip_addr})"

return friendly


def pulse_sink_name_to_label(sink_name: str | None) -> str:
if not sink_name or sink_name in ("default", "auto"):
return "default"

if sink_name.startswith("pulse/"):
sink_name = sink_name.removeprefix("pulse/")

if sink_name.startswith("raop_sink."):
return _label_for_raop_sink(sink_name)

return sink_name


def list_pulse_sink_label_map(*extra_sink_names: str | None) -> dict[str, str]:
label_map: dict[str, str] = {"default": "default"}

sink_names = list_pulse_sink_names()

for extra in extra_sink_names:
if extra and extra not in ("default", "auto"):
if extra.startswith("pulse/"):
extra = extra.removeprefix("pulse/")
sink_names.append(extra)

for sink_name in sorted(set(sink_names)):
base_label = pulse_sink_name_to_label(sink_name)
label = base_label

if label in label_map and label_map[label] != sink_name:
label = sink_name

label_map[label] = sink_name

return label_map
53 changes: 52 additions & 1 deletion linux_voice_assistant/entity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from abc import abstractmethod
from collections.abc import Iterable
from typing import Callable, List, Optional, Union
from typing import Callable, Iterable, List, Optional, Union

# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
Expand Down Expand Up @@ -446,6 +446,57 @@ def update_set_value(self, set_value: Callable[[Union[float, str]], None]) -> No
# -----------------------------------------------------------------------------



class AudioOutputSinkEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_value: Callable[[], str],
set_value: Callable[[str], None],
options: List[str],
icon: str = "mdi:speaker-wireless",
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self._get_value = get_value
self._set_value = set_value
self.options = options
self.icon = icon
self._state = self._get_value()

def sync_with_state(self) -> None:
self._state = self._get_value()

def update_options(self, options: List[str]) -> None:
self.options = options

def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, SelectCommandRequest) and msg.key == self.key:
new_val = msg.state
self._state = new_val
self._set_value(new_val)
yield SelectStateResponse(key=self.key, state=new_val)

if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesSelectResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
options=self.options,
entity_category=EntityCategory.CONFIG,
icon=self.icon,
)

elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
yield SelectStateResponse(key=self.key, state=str(self._state))


class WakeWord1SensitivityNumberEntity(ESPHomeEntity):
def __init__(
self,
Expand Down
22 changes: 22 additions & 0 deletions linux_voice_assistant/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pyopen_wakeword import OpenWakeWord

from .entity import (
AudioOutputSinkEntity,
ESPHomeEntity,
MediaPlayerEntity,
MicSettingEntity,
Expand Down Expand Up @@ -61,6 +62,7 @@ def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":

@dataclass
class Preferences:
audio_output_sink: Optional[str] = None
active_wake_words: List[Optional[str]] = field(default_factory=list)
volume: Optional[float] = None
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
Expand Down Expand Up @@ -100,6 +102,8 @@ class ServerState:
download_dir: Path
continue_conversation_delay: float = 0.5 # seconds to wait after TTS before opening mic

audio_output_sink: Optional[str] = None
audio_output_sink_entity: "Optional[AudioOutputSinkEntity]" = None
media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
Expand Down Expand Up @@ -129,6 +133,24 @@ class ServerState:
audio_input_channels: int = 2 # number of mic channels to stream
timer_max_ring_seconds: float = 900.0



def persist_audio_output_sink(self, sink_name: Optional[str]) -> None:
"""Persist the selected PulseAudio/PipeWire output sink."""
if sink_name == "default":
sink_name = None

if (
self.audio_output_sink == sink_name
and self.preferences.audio_output_sink == sink_name
):
return

self.audio_output_sink = sink_name
self.preferences.audio_output_sink = sink_name
_LOGGER.info("Saving audio_output_sink %s to %s", sink_name, self.preferences_path)
self.save_preferences()

def save_preferences(self) -> None:
"""Save preferences as JSON."""
_LOGGER.debug("Saving preferences: %s", self.preferences_path)
Expand Down
4 changes: 4 additions & 0 deletions linux_voice_assistant/mpv_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ def resume(self) -> None:
self._log.debug("resume() called")
self._player.resume()

def set_audio_device(self, device: str | None) -> None:
"""Set the mpv audio output device at runtime."""
self._player.set_audio_device(device)

def stop(self) -> None:
"""Stop playback and invoke the done callback if present."""
self._log.debug("stop() called")
Expand Down
12 changes: 12 additions & 0 deletions linux_voice_assistant/player/libmpv.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ def resume(self) -> None:
self._mpv.pause = False
self._set_state(PlayerState.PLAYING)

def set_audio_device(self, device: str | None) -> None:
"""Set the mpv audio output device at runtime."""
with self._state_lock:
self._mpv.stop()

if device:
self._mpv["audio-device"] = device
else:
self._mpv["audio-device"] = "auto"

self._set_state(PlayerState.IDLE)

def stop(self, for_replacement: bool = False) -> None:
"""
Stop playback.
Expand Down
60 changes: 60 additions & 0 deletions linux_voice_assistant/satellite.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,14 @@
from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord

from .audio_sinks import (
list_pulse_sink_label_map,
pulse_sink_name_to_label,
pulse_sink_to_mpv_device,
)
from .api_server import APIServer
from .entity import (
AudioOutputSinkEntity,
MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity,
Expand Down Expand Up @@ -330,6 +336,60 @@ def _set_noise_label(label: Union[float, str]) -> None:
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
self._disconnect_event = asyncio.Event()


# Audio Output Sink
audio_sink_label_map = list_pulse_sink_label_map(self.state.audio_output_sink)
current_audio_sink_label = pulse_sink_name_to_label(self.state.audio_output_sink)

if current_audio_sink_label not in audio_sink_label_map:
audio_sink_label_map[current_audio_sink_label] = self.state.audio_output_sink

audio_sink_options = list(audio_sink_label_map.keys())

if self.state.audio_output_sink_entity is None:
self.state.audio_output_sink_entity = AudioOutputSinkEntity(
server=self,
key=len(self.state.entities),
name="Audio Output Sink",
object_id="audio_output_sink",
get_value=lambda: pulse_sink_name_to_label(self.state.audio_output_sink),
set_value=self._set_audio_output_sink,
options=audio_sink_options,
icon="mdi:speaker-wireless",
)
self.state.entities.append(self.state.audio_output_sink_entity)
elif self.state.audio_output_sink_entity not in self.state.entities:
self.state.entities.append(self.state.audio_output_sink_entity)

self.state.audio_output_sink_entity.server = self
self.state.audio_output_sink_entity.update_options(audio_sink_options)
self.state.audio_output_sink_entity.sync_with_state()


def _set_audio_output_sink(self, sink_label: str) -> None:
"""Set the selected PulseAudio/PipeWire output sink."""
audio_sink_label_map = list_pulse_sink_label_map()
persisted_sink = audio_sink_label_map.get(sink_label)

if persisted_sink is None and sink_label != "default":
persisted_sink = sink_label

mpv_device = pulse_sink_to_mpv_device(persisted_sink)

_LOGGER.info(
"Setting audio output sink: label=%s sink=%s mpv_device=%s",
sink_label,
persisted_sink,
mpv_device,
)

self.state.music_player.set_audio_device(mpv_device)
self.state.tts_player.set_audio_device(mpv_device)
self.state.persist_audio_output_sink(persisted_sink)

if self.state.audio_output_sink_entity is not None:
self.state.audio_output_sink_entity.sync_with_state()

def _set_thinking_sound_enabled(self, new_state: bool) -> None:
self.state.thinking_sound_enabled = bool(new_state)
self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0
Expand Down
Loading