Files
wyoming-parakeet-mlx/install.sh
T
aharrison-fullerandClaude Opus 5 4286e88344 Wyoming speech-to-text server for Home Assistant using NVIDIA Parakeet
Loads parakeet-mlx in-process (no HTTP hop) and serves it over the Wyoming
protocol. On an M4 Mac mini this transcribes typical voice commands in ~110ms
versus ~1150ms for a whisper.cpp large-v3 setup, with identical accuracy on a
ten-command benchmark.

Two behaviours matter beyond speed: silence returns an empty string rather
than whisper's "Thank you." hallucination, and there is no decoder context
carried between requests.

Notable implementation details, all covered by mutation-checked regression
tests:

- MLX streams are thread-local, so the model is loaded and evaluated on a
  single dedicated worker thread. Splitting those raises
  "There is no Stream(cpu, 1) in current thread".
- parakeet_mlx.load_audio() shells out to ffmpeg, which is unnecessary here
  since Wyoming delivers 16kHz mono PCM. The mel is built directly via
  get_logmel(), whose input must be float32 -- it views the complex STFT
  output as the input dtype, so anything narrower doubles the mel bin count.
- Wyoming's run loop has no except clause, so an exception escaping
  handle_event closes the connection without sending a Transcript and Home
  Assistant waits indefinitely. Failures are caught and returned as an empty
  transcript instead.

Defaults to parakeet-tdt-0.6b-v2 rather than the newer multilingual v3
because v2 emits digits ("21 degrees") where v3 spells numbers out, and
Home Assistant's local intent matching expects digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:04:48 +01:00

188 lines
6.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Install wyoming-parakeet and (optionally) register it as a LaunchDaemon.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV="$REPO_DIR/.venv"
LABEL="local.wyoming-parakeet"
PLIST="/Library/LaunchDaemons/$LABEL.plist"
PORT=7892
MODEL="mlx-community/parakeet-tdt-0.6b-v2"
SERVICE_USER="$(id -un)"
PYTHON=""
INSTALL_DAEMON=1
DOWNLOAD_MODEL=1
usage() {
cat <<EOF
Usage: ./install.sh [options]
--port PORT Wyoming port (default: $PORT)
--model ID HuggingFace model id (default: $MODEL)
--user USER User the daemon runs as (default: $SERVICE_USER)
--python PATH Python interpreter to build the venv from
--no-daemon Set up the venv only; don't install the LaunchDaemon
--no-download Skip pre-downloading the model
-h, --help Show this help
Installs in place, so keep the checkout somewhere permanent.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--model) MODEL="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--python) PYTHON="$2"; shift 2 ;;
--no-daemon) INSTALL_DAEMON=0; shift ;;
--no-download) DOWNLOAD_MODEL=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
die() { echo "error: $*" >&2; exit 1; }
# --- preflight -------------------------------------------------------------
[[ "$(uname -s)" == "Darwin" ]] || die "macOS only (MLX is Apple Silicon)."
[[ "$(uname -m)" == "arm64" ]] || die "Apple Silicon required; this is $(uname -m)."
id -u "$SERVICE_USER" >/dev/null 2>&1 || die "no such user: $SERVICE_USER"
# librosa pulls in pooch, which imports lzma. Pythons built without xz (a
# common pyenv default) satisfy every version check and then fail at import
# time with ModuleNotFoundError: _lzma -- so check for it up front.
usable_python() {
local py resolved
py="$1"
resolved="$(command -v "$py" 2>/dev/null)" || return 1
[[ -x "$resolved" ]] || return 1
"$resolved" -c 'import sys, lzma; sys.exit(0 if sys.version_info >= (3, 10) else 1)' \
>/dev/null 2>&1 || return 1
echo "$resolved"
}
if [[ -n "$PYTHON" ]]; then
PYTHON="$(usable_python "$PYTHON")" \
|| die "$PYTHON is unusable: needs >=3.10 and the lzma module."
else
# Search PATH *and* the usual Homebrew prefixes explicitly. A
# non-interactive shell often has neither on PATH, which would otherwise
# leave us falling back to the system python3 (3.9, too old).
for candidate in \
python3.14 python3.13 python3.12 python3.11 python3 \
/opt/homebrew/bin/python3.1{4,3,2,1} /opt/homebrew/bin/python3 \
/usr/local/bin/python3.1{4,3,2,1} /usr/local/bin/python3
do
if PYTHON="$(usable_python "$candidate")"; then
break
fi
PYTHON=""
done
[[ -n "$PYTHON" ]] || die "no suitable python found (needs >=3.10 with the lzma module; try: brew install python@3.13)"
fi
echo "==> Python: $PYTHON ($("$PYTHON" -V 2>&1))"
echo "==> Install: $REPO_DIR"
echo "==> Model: $MODEL"
echo "==> Port: $PORT"
# --- venv ------------------------------------------------------------------
echo "==> Creating virtualenv"
"$PYTHON" -m venv "$VENV"
"$VENV/bin/pip" install --quiet --upgrade pip
echo "==> Installing dependencies (this pulls ~600MB of MLX wheels)"
"$VENV/bin/pip" install --quiet -r "$REPO_DIR/requirements.txt"
echo "==> Running unit tests"
"$VENV/bin/pip" install --quiet pytest pytest-asyncio
( cd "$REPO_DIR" && "$VENV/bin/python" -m pytest -q )
if [[ "$DOWNLOAD_MODEL" -eq 1 ]]; then
echo "==> Downloading $MODEL (~2.3GB on first run)"
HF_HUB_OFFLINE= "$VENV/bin/python" - "$MODEL" <<'EOF'
import sys
from parakeet_mlx import from_pretrained
from_pretrained(sys.argv[1])
EOF
fi
if [[ "$INSTALL_DAEMON" -eq 0 ]]; then
echo
echo "Done (no daemon installed). Run it with:"
echo " $REPO_DIR/script/run --uri tcp://0.0.0.0:$PORT --model $MODEL"
exit 0
fi
# --- launchd ---------------------------------------------------------------
echo "==> Installing LaunchDaemon (needs sudo)"
TMP_PLIST="$(mktemp -t wyoming-parakeet)"
trap 'rm -f "$TMP_PLIST"' EXIT
cat > "$TMP_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$REPO_DIR/script/run</string>
<string>--uri</string>
<string>tcp://0.0.0.0:$PORT</string>
<string>--model</string>
<string>$MODEL</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HF_HUB_OFFLINE</key><string>1</string>
<key>HOME</key><string>$(eval echo "~$SERVICE_USER")</string>
</dict>
<key>UserName</key><string>$SERVICE_USER</string>
<key>GroupName</key><string>staff</string>
<key>InitGroups</key><true/>
<key>WorkingDirectory</key><string>$REPO_DIR</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Interactive</string>
<key>LowPriorityIO</key><false/>
<key>StandardOutPath</key><string>/tmp/$LABEL.stdout</string>
<key>StandardErrorPath</key><string>/tmp/$LABEL.stderr</string>
</dict>
</plist>
EOF
plutil -lint "$TMP_PLIST" >/dev/null || die "generated plist is malformed"
sudo cp "$TMP_PLIST" "$PLIST"
sudo chown root:wheel "$PLIST"
sudo chmod 644 "$PLIST"
sudo launchctl bootout "system/$LABEL" 2>/dev/null || true
sudo launchctl bootstrap system "$PLIST"
echo "==> Waiting for the service to come up"
for _ in $(seq 1 60); do
if nc -z 127.0.0.1 "$PORT" 2>/dev/null; then
echo " listening on $PORT"
break
fi
sleep 2
done
nc -z 127.0.0.1 "$PORT" 2>/dev/null || die "service did not start; check /tmp/$LABEL.stderr"
cat <<EOF
Done. Add it in Home Assistant under Settings -> Devices & Services ->
Add Integration -> Wyoming Protocol, using this host and port $PORT,
then select the new engine as the speech-to-text step in your Assist pipeline.
logs: tail -f /tmp/$LABEL.stderr
verify: $VENV/bin/python test/wy-test.py test/clips/*.wav
(run test/make-clips.sh first to generate the clips)
EOF