Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
efb42c8
Add files via upload
omaramin-2000 Mar 14, 2026
06eb194
Delete sound files
omaramin-2000 Mar 14, 2026
8cbfe6b
Merge branch 'OHF-Voice:main' into main
omaramin-2000 Mar 15, 2026
f9442de
Merge branch 'OHF-Voice:main' into main
omaramin-2000 Mar 15, 2026
7ff5762
Merge branch 'OHF-Voice:main' into main
omaramin-2000 Mar 22, 2026
02d0493
Merge branch 'OHF-Voice:main' into main
omaramin-2000 Mar 23, 2026
e9dbd12
Merge branch 'OHF-Voice:main' into main
omaramin-2000 Mar 24, 2026
b6c1029
Add maximum seconds for ringing timer
omaramin-2000 Mar 24, 2026
94dedba
Add maximum seconds for ringing timer
omaramin-2000 Mar 24, 2026
9802d7b
Add maximum seconds for ringing timer
omaramin-2000 Mar 24, 2026
d683576
Update satellite.py
omaramin-2000 Mar 24, 2026
ccabeea
Update satellite.py
omaramin-2000 Mar 24, 2026
e19dfb8
Merge branch 'OHF-Voice:main' into main
omaramin-2000 Mar 24, 2026
531b59c
Fix lint formatting
omaramin-2000 Mar 25, 2026
4f44609
Update satellite.py
omaramin-2000 Mar 25, 2026
a45a387
Update __main__.py
omaramin-2000 Mar 25, 2026
443c8bb
Update satellite.py
omaramin-2000 Mar 25, 2026
f0246c2
Update satellite.py
omaramin-2000 Mar 25, 2026
8a6d975
i messed up the git and its betteer now
machineonamission Mar 27, 2026
8c447d6
squash a bunch of git push based debugging
machineonamission Mar 27, 2026
c71d181
undo autoformatting and debugging stuff
machineonamission Mar 27, 2026
f5c99fb
dammit
machineonamission Mar 27, 2026
2550c2d
add docs
machineonamission Mar 27, 2026
65d5e17
its one commit cause i messed up the base commit TWICE
machineonamission Mar 27, 2026
e0bb3a8
why
machineonamission Mar 27, 2026
413bb73
Merge branch 'main' into main
florian-asche Mar 30, 2026
105ba85
testing pure pactl impl
machineonamission Apr 25, 2026
0052284
wabelabebl
machineonamission Apr 25, 2026
acf29a4
wabelabebl
machineonamission Apr 25, 2026
4169858
wabelabebl
machineonamission Apr 25, 2026
ef8603b
wabelabebl
machineonamission Apr 25, 2026
00a41ec
wabelabebl
machineonamission Apr 25, 2026
82fe9d2
Merge branch 'pw-volume'
machineonamission Apr 25, 2026
606a374
Merge remote-tracking branch 'upstream/main'
machineonamission Apr 25, 2026
9133163
i am so stupid
machineonamission Apr 25, 2026
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
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ RUN apt-get update && \
ca-certificates \
iproute2 \
vim \
procps && \
procps \
&& \
apt-get clean

### Set workdir:
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ For all other users, we have different installation methods available (Docker, s
``` sh
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug] [--volume-controller VOLUME_CONTROLLER]
```

| Parameter | Description | Default |
Expand Down Expand Up @@ -82,12 +82,14 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--port` | Port for ESPHome server | 6053 |
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
| `--debug` | Print DEBUG messages to console | False |
| `--volume-controller` | Which program handles the master volume control. See [Additional Audio Settings](docs/additional_audio_settings.md#pipewire-based-volume-control) | `mpv` |
| `--output-only` | Enable output only mode | False |

💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.


## Build Information

## Build Information:

Image builds can be tracked in this repository's `Actions` tab, and utilize [artifact attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) to certify provenance.

Expand Down
4 changes: 4 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
EXTRA_ARGS+=( "--output-only" )
fi

if [ -n "${VOLUME_CONTROLLER}" ]; then
EXTRA_ARGS+=( "--volume-controller" "$VOLUME_CONTROLLER" )
fi


# Add cookie file for pulseaudio to prevent errors
PULSE_COOKIE=${PULSE_COOKIE:-"/run/user/1000/pulse/cookie"}
Expand Down
41 changes: 41 additions & 0 deletions docs/additional_audio_settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Additional Audio Settings

This document is still in an early stage and will be updated soon.

## Pipewire:

### Pipewire-based Volume Control:

If you set the environment variable `VOLUME_CONTROLLER` to `pipewire`, LVA will use Pipewire for volume control, which
often works with USB volume controls/displays. You may need a daemon to handle external volume buttons, such
as [alsa_volume_from_usb_hid](https://github.com/neildavis/alsa_volume_from_usb_hid)
or [its docker version](https://github.com/machineonamission/alsa_volume_from_usb_hid).

## Pulseaudio:

### Acoustic Echo Cancellation:

Enable the echo cancel PulseAudio module:

``` sh
pactl load-module module-echo-cancel \
aec_method=webrtc \
aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
```

Verify that the `echo-cancel-source` and `echo-cancel-sink` devices are present:

``` sh
pactl list short sources
pactl list short sinks
```

Use the new devices:

``` sh
# The device names may be different on your system.
# Double check with --list-input-devices and --list-output-devices
python3 -m linux_voice_assistant ... \
--audio-input-device 'Echo-Cancel Source' \
--audio-output-device 'pipewire/echo-cancel-sink'
```
2 changes: 2 additions & 0 deletions docs/install_application.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ The following variables can be configured in the `.env` or in the service file:
| `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
| `MUTE_SOUND` | `sounds/mute_switch_on.flac` | Sound file for mute on |
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for Configure Audio Devices |
| `VOLUME_CONTROLLER` | `mpv` | Which program handles the master volume control. See [Additional Audio Settings](docs/additional_audio_settings.md#pipewire-based-volume-control) |
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for Configure Audio Devices
| `ENABLE_OUTPUT_ONLY` | (optional) | Set to "1" to enable output-only mode |

Expand Down
10 changes: 10 additions & 0 deletions linux_voice_assistant/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ async def main() -> None:
action="store_true",
help="Enable output only mode",
)
parser.add_argument(
"--volume-controller",
choices=["mpv", "pipewire"],
default="mpv",
help="Which program handles the master volume control. 'mpv' is old default, and compatible. "
"'pipewire' requires wpctl, but in general interfaces with USB audio devices and pipewire better. "
"Defaults to 'mpv'. Must be 'mpv' or 'pipewire'.",
)
args = parser.parse_args()

if args.list_input_devices:
Expand Down Expand Up @@ -326,6 +334,8 @@ async def main() -> None:
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
timer_max_ring_seconds=args.timer_max_ring_seconds,
volume_controller=args.volume_controller,
audio_output_device=args.audio_output_device
)

if fallback_used:
Expand Down
121 changes: 111 additions & 10 deletions linux_voice_assistant/entity.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import asyncio
import json
import logging
import re
import threading
from abc import abstractmethod
from collections.abc import Iterable
import subprocess
from typing import Callable, List, Optional, Union

# pylint: disable=no-name-in-module
Expand Down Expand Up @@ -56,6 +61,16 @@ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
# -----------------------------------------------------------------------------


async def get_stdout(*args):
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
stdin=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
return stdout.decode('utf-8').strip()

class MediaPlayerEntity(ESPHomeEntity):
def __init__(
self,
Expand Down Expand Up @@ -83,6 +98,98 @@ def __init__(
self.apply_volume_from_state(initial_volume)
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")

if hasattr(self.server, "state") and self.server.state.volume_controller in ["pipewire", "pulseaudio"]:
asyncio.get_running_loop().create_task(self.volume_monitor_loop())

async def pw_vol(self):
# a bit verbose but otherwise robust and awk/grep/regex-less
# no get-sink-volume does not support -f json, otherwise i would have DONE that
def_sink = await get_stdout("pactl", "get-default-sink")
sinks = json.loads(await get_stdout("pactl", "-f", "json", "list", "sinks"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think writing the pactl output to stdout is the right long-term approach.


def_sink_info = None

for sink in sinks:
if sink.get("name") == def_sink:
def_sink_info = sink
break
else:
def_sink_info = sinks[0]

volumes = []
for volume in def_sink_info["volume"].values():
volumes.append(float(volume["value_percent"].replace("%", "")/100.0))


vol = sum(volumes) / len(volumes)

return vol

async def volume_monitor_loop(self):
while True:
try:
process = await asyncio.create_subprocess_exec(
"pactl", "-f", "json", "subscribe",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
stdin=asyncio.subprocess.DEVNULL,
)
while True:
# 2. Thread sleeps here until PipeWire actually changes state
line = await process.stdout.readline()

line = json.loads(line.decode('utf-8').strip())

if line.get("event") == "change" and line.get("on") == "sink":
self._log.debug(f"pactl subscribe event {line}")
volume = await self.pw_vol()
self._log.debug(f"new volume: {volume}")

normalized = max(0.0, min(1.0, float(volume)))

self.volume = normalized
self.previous_volume = normalized

if self._on_volume_changed:
self._on_volume_changed(normalized)

self.server.state.persist_volume(normalized)

self.server.send_messages([self._get_state_message()])

except Exception as e:
self._log.error("Error in volume monitor loop: %s", e, e.__traceback__)
await asyncio.sleep(1) # Avoid tight error loop

def set_volume(self, volume: float) -> None:
# self._log.debug("Setting volume: %.2f", volume)
self.volume = volume
if hasattr(self.server, "state") and self.server.state.volume_controller in ["pipewire", "pulseaudio"]:
def _update_system_vol():
try:
# vol_percent = f"{int(round(volume * 100))}%"
self._log.debug("pactl start")
res = subprocess.run(
["pactl", "set-sink-volume", self.server.state.audio_output_device or "@DEFAULT_SINK@", str(volume)],
check=False,
capture_output=True,
text=True,
timeout=1
)
if res.returncode == 0:
self._log.debug("pactl success")
else:
self._log.error("pactl failed with error code: %s", res.returncode)
except Exception as e:
self._log.error("pactl err ", e)
# self._log.error("Volume command failed: %s", e)

threading.Thread(target=_update_system_vol, daemon=True).start()
else:
percent = int(round(volume * 100))
self.music_player.set_volume(percent)
self.announce_player.set_volume(percent)

def play(
self,
url: Union[str, List[str]],
Expand Down Expand Up @@ -159,18 +266,14 @@ def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
self._log.debug("Executing MUTE")
if not self.muted:
self.previous_volume = self.volume
self.volume = 0
self.music_player.set_volume(0)
self.announce_player.set_volume(0)
self.set_volume(0)
self.muted = True
yield self._update_state(self.state)

elif command == MediaPlayerCommand.UNMUTE:
self._log.debug("Executing UNMUTE")
if self.muted:
self.volume = self.previous_volume
self.music_player.set_volume(int(self.volume * 100))
self.announce_player.set_volume(int(self.volume * 100))
self.set_volume(self.previous_volume)
self.muted = False
yield self._update_state(self.state)

Expand Down Expand Up @@ -237,12 +340,10 @@ def _apply_volume(
remember: bool = True,
) -> None:
normalized = max(0.0, min(1.0, float(volume)))
volume_percent = int(round(normalized * 100))

self.music_player.set_volume(volume_percent)
self.announce_player.set_volume(volume_percent)
self.set_volume(normalized)

self.volume = normalized
# self.volume = normalized

if remember:
self.previous_volume = normalized
Expand Down
2 changes: 2 additions & 0 deletions linux_voice_assistant/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ class ServerState:
mic_noise_suppression: int = 0
mic_volume: int = 100 # 1–100, default maximum
timer_max_ring_seconds: float = 900.0
volume_controller: str = "mpv"
audio_output_device: Optional[str] = None

def save_preferences(self) -> None:
"""Save preferences as JSON."""
Expand Down
Loading