6 Commits
v6 ... v10

Author SHA1 Message Date
MasterPhooey
a1b22200e0 Point Docker trainer at native Tater firmware 2026-07-11 08:35:38 -05:00
MasterPhooey
89260f1f14 Add Nvidia Docker release notes 2026-06-27 09:39:47 -05:00
MasterPhooey
0140dfb56f Fix Docker image release tags 2026-06-27 09:36:59 -05:00
MasterPhooey
1fc7d80bae Add RTX 50 Blackwell image support 2026-06-27 07:46:53 -05:00
MasterPhooey
31a6388da4 Improve trainer capture metadata and USB flashing 2026-06-27 06:39:58 -05:00
MasterPhooey
85c2d6334b Show MIT RIR download progress 2026-06-18 23:25:30 -05:00
8 changed files with 724 additions and 83 deletions

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch:
permissions:
contents: read
contents: write
packages: write
concurrency:
@@ -41,6 +41,8 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=latest
type=ref,event=tag
@@ -56,3 +58,69 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=mww-trainer-nvidia-docker
cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker
- name: Docker metadata (Blackwell)
id: meta-blackwell
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=blackwell
type=ref,event=tag,suffix=-blackwell
- name: Build and push Blackwell image
uses: docker/build-push-action@v6
with:
context: .
file: dockerfile.blackwell
platforms: linux/amd64
push: true
tags: ${{ steps.meta-blackwell.outputs.tags }}
labels: ${{ steps.meta-blackwell.outputs.labels }}
cache-from: type=gha,scope=mww-trainer-nvidia-docker-blackwell
cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker-blackwell
- name: Create release notes
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
title="microWakeWord Nvidia Trainer ${TAG_NAME}"
generated_notes="$(mktemp)"
release_notes="$(mktemp)"
gh api "repos/${REPO}/releases/generate-notes" \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${GITHUB_SHA}" \
--jq '.body' > "${generated_notes}"
{
echo "## Docker Images"
echo
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:latest\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}-blackwell\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:blackwell\`"
echo
cat "${generated_notes}"
} > "${release_notes}"
if gh release view "${TAG_NAME}" >/dev/null 2>&1; then
gh release edit "${TAG_NAME}" \
--title "${title}" \
--notes-file "${release_notes}" \
--latest \
--verify-tag
else
gh release create "${TAG_NAME}" \
--title "${title}" \
--notes-file "${release_notes}" \
--latest \
--verify-tag
fi

View File

@@ -22,9 +22,22 @@ docker pull ghcr.io/tatertotterson/microwakeword:latest
Tagged releases also publish matching immutable image tags:
```bash
docker pull ghcr.io/tatertotterson/microwakeword:v5
docker pull ghcr.io/tatertotterson/microwakeword:v10
```
RTX 50-series / Blackwell GPUs use a separate image with CUDA 12.8 and a
Python 3.13 TensorFlow build for `sm_120`:
```bash
docker pull ghcr.io/tatertotterson/microwakeword:blackwell
docker pull ghcr.io/tatertotterson/microwakeword:v10-blackwell
```
Use the Blackwell image only for RTX 50-series cards. It includes the
community-built TensorFlow wheel from
[chivitiH/tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313),
which is unofficial and licensed CC BY-NC 4.0.
---
## Run The Container
@@ -38,12 +51,15 @@ docker run -d \
ghcr.io/tatertotterson/microwakeword:latest
```
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v5` when you want to pin a known release instead of tracking `latest`.
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v10` when you want to pin a known release instead of tracking `latest`.
For RTX 50-series cards, use `ghcr.io/tatertotterson/microwakeword:blackwell`
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v10-blackwell`
in the same `docker run` command.
The flags:
- `--gpus all` enables GPU acceleration.
- `--network host` lets the container receive mDNS/zeroconf traffic for ESPHome auto-detect.
- `--network host` lets the container receive mDNS/zeroconf traffic for device auto-detect.
- `-e REC_PORT=8789` sets the trainer web UI and captured-audio port. Change this value if `8789` is already in use.
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and firmware caches.
@@ -55,14 +71,14 @@ Open:
http://localhost:8789
```
If you change `REC_PORT`, open that port instead and use the same port in the ESPHome `Trainer App URL`.
If you change `REC_PORT`, open that port instead and use the same port in the satellite `Trainer App URL`.
---
## What The UI Does
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training.
- `Captured Audio` reviews clips sent by ESPHome sats, including wake hits, close misses, and false wakes.
- `Captured Audio` reviews clips sent by Tater Native or ESPHome sats, including wake hits, close misses, and false wakes.
- `Samples` plays, removes, clears, and manually imports personal or negative samples.
- `Firmware` pulls verified prebuilt Tater firmware images from GitHub and flashes supported satellites over OTA.
- Popup consoles show colorized training and firmware logs while long-running jobs are active.
@@ -71,15 +87,21 @@ If you change `REC_PORT`, open that port instead and use the same port in the ES
## Captured Audio Workflow
To collect samples from a sat, flash it with the Tater firmware from [TaterTotterson/microWakeWords](https://github.com/TaterTotterson/microWakeWords). The `Firmware` tab can pull verified prebuilt OTA images from that repo for fast firmware updates.
To collect samples from a sat, point its trainer feedback setting at this app. Tater Native satellites use the native settings popup in Tater. Older ESPHome satellites can still use their device entities.
After flashing, the device exposes ESPHome entities for capture setup:
For Tater Native satellites, enable trainer feedback in Tater:
- `Send Good Wakes To Trainer` toggles upload of confirmed wake-word triggers.
- `Send Close Misses To Trainer` toggles upload of near misses.
- `Trainer App URL` sets the trainer address, for example `http://trainer.local:8789` or `http://<trainer-ip>:8789`.
For older ESPHome firmware, the equivalent capture setup is exposed as device entities:
- `Capture Wake Audio` toggles upload of wake-word triggers.
- `Capture Close Misses` toggles upload of near misses.
- `Trainer App URL` sets the trainer address, for example `http://<trainer-ip>:8789`.
ESPHome devices can send raw captured audio to:
Satellites send raw captured audio to:
```text
/api/upload_captured_audio_raw
@@ -153,6 +175,8 @@ Personal samples are optional. Training can run with zero personal samples after
Reviewed negative samples are converted into `/data/work/reviewed_negative_features/` and inserted into the training YAML as a hard-negative feature set when present.
On RTX 50-series / Blackwell GPUs, the Blackwell Docker image keeps sample generation and augmentation in the normal Python 3.12 trainer environment, then runs only the TensorFlow training/export stage in `/data/.venv-blackwell` with Python 3.13 and the Blackwell-native TensorFlow wheel.
---
## Language Support
@@ -185,18 +209,21 @@ After those assets are prepared, later runs reuse the local copies unless the mo
## Firmware Flashing
The `Firmware` tab flashes prebuilt Tater firmware for supported ESPHome satellites.
The `Firmware` tab flashes prebuilt Tater firmware for supported satellites.
- Downloads the latest prebuilt firmware manifest plus OTA and USB factory images from `TaterTotterson/microWakeWords`.
- Downloads the latest prebuilt firmware manifest plus OTA and USB factory images from [`TaterTotterson/Tater-Native-Firmware`](https://github.com/TaterTotterson/Tater-Native-Firmware).
- Verifies downloaded images by size and SHA before upload.
- Auto-detects ESPHome devices with mDNS when the container is running with host networking.
- Auto-detects compatible devices with mDNS when the container is running with host networking.
- Allows manual IP or hostname entry if discovery does not find the device.
- Saves the selected OTA target for each firmware family.
- Flashes the prebuilt factory image over Browser USB for first installs or recovery when opened in Chrome or Edge.
- Leaves Wi-Fi, Tater server, and pairing setup to the satellite setup portal after USB flash.
- Lists locally trained wake words from `/data/trained_wake_words/` for live model switching.
- Streams download, verification, and OTA upload progress in a colorized firmware console.
You usually only flash for firmware updates. New satellites, or devices older than Tater firmware `3.0.3`, need one USB flash first before OTA updates and live wake-word switching are available.
> **Tater only:** these native firmware images connect to Tater. They are not Home Assistant or ESPHome satellite firmware.
You usually only flash for firmware updates. New satellites, or devices not already running Tater Native Firmware `v1`, need one USB flash first before OTA updates and live wake-word switching are available.
---
@@ -218,6 +245,42 @@ The trainer also syncs firmware-ready artifacts into:
The firmware tab uses `/data/trained_wake_words/` to populate the wake-word dropdown.
The JSON keeps the standard microWakeWord fields for compatibility:
```json
{
"micro": {
"probability_cutoff": 0.97,
"sliding_window_size": 5
}
}
```
It also includes Tater Native metadata used by newer satellites and the Tater settings UI:
```json
{
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"tater_native": {
"format_version": 1,
"wake_threshold": 0.97,
"wake_sliding_window": 5,
"close_miss_threshold": 0.78,
"frontend": {
"name": "tflm_microfrontend",
"sample_rate": 16000,
"feature_duration_ms": 30,
"feature_step_ms": 10,
"feature_size": 40
}
}
}
```
Calibration metrics are included under `calibration` so false accepts/hour and recall can be surfaced in the UI.
---
## Resetting Everything
@@ -243,7 +306,7 @@ That removes:
- Negative samples are optional but useful for reducing false wakes.
- The UI server is `trainer_server.py`.
- The launcher is `run.sh`.
- Firmware capture settings live on the ESPHome device and can be toggled from the device entities after flashing.
- Firmware capture settings live in Tater for Tater Native satellites, and on device entities for older ESPHome satellites.
---
@@ -253,3 +316,4 @@ Built on top of:
- [microWakeWord](https://github.com/kahrendt/microWakeWord)
- [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator)
- [tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313) for the optional RTX 50-series / Blackwell image

112
cli/setup_blackwell_venv Executable file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
set -euo pipefail
PROGDIR="$(dirname "$(realpath "$0")")"
ROOTDIR="$(dirname "${PROGDIR}")"
KNOWN_ARGS=( data-dir force python )
source "${PROGDIR}/shell.functions"
if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then
echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2
HELP=true
fi
if [ "${HELP}" == "true" ] ; then
cat <<EOF >&2
Usage: setup_blackwell_venv [ --data-dir=/data ] [ --force ] [ --python=python3.13 ]
Creates /data/.venv-blackwell for RTX 50 / Blackwell TensorFlow training.
Sample generation and augmentation continue to use /data/.venv.
Environment overrides:
MWW_BLACKWELL_TF_WHEEL_URL: TensorFlow Blackwell wheel URL.
EOF
exit 1
fi
[ -n "${DATA_DIR}" ] && DATA_DIR="$(realpath "${DATA_DIR}")"
[ -d "${DATA_DIR}" ] || {
echo "Data directory '${DATA_DIR}' doesn't exist." >&2
exit 1
}
PYTHON="${PYTHON:-python3.13}"
VENV="${DATA_DIR}/.venv-blackwell"
MARKER="${VENV}/.mww-blackwell-venv"
TF_WHEEL_URL="${MWW_BLACKWELL_TF_WHEEL_URL:-https://github.com/chivitiH/tensorflow-blackwell-python313/releases/download/v2.22.0-selfbuilt/tensorflow-2.22.0.dev0+selfbuilt-cp313-cp313-linux_x86_64.whl}"
if ! command -v "${PYTHON}" >/dev/null 2>&1 ; then
echo "Python 3.13 is required for the Blackwell TensorFlow wheel. Missing: ${PYTHON}" >&2
exit 1
fi
if [ "${FORCE:-false}" != "true" ] && [ -x "${VENV}/bin/python" ] && [ -f "${MARKER}" ] ; then
echo " Blackwell TensorFlow venv found (skipping setup_blackwell_venv)"
exit 0
fi
echo "===== Setting up Blackwell TensorFlow environment ${VENV} ====="
rm -rf "${VENV}" || :
"${PYTHON}" -m venv --upgrade-deps "${VENV}"
source "${VENV}/bin/activate"
export PIP_PROGRESS_BAR=off
export PIP_NO_COLOR=1
export PIP_QUIET=0
pip_install() {
if $VERBOSE ; then
pip install "$@" || return 1
else
{ pip install "$@" || return 1 ; } | stdbuf -i0 -o0 tr -d '[:print:]' | stdbuf -i0 -o0 tr '\n' '.'
fi
echo
}
echo " ===== Installing Blackwell TensorFlow wheel ====="
pip_install --upgrade pip setuptools wheel
pip_install "${TF_WHEEL_URL}"
echo " ===== Installing microWakeWord training dependencies ====="
pip_install \
audiomentations \
audio_metadata \
datasets \
mmap_ninja \
pymicro-features \
pyyaml \
webrtcvad-wheels \
ai-edge-litert \
numpy-minmax \
numpy-rms \
absl-py \
"numpy==2.3.5"
echo " ===== Checking microwakeword ====="
MWW="${DATA_DIR}/tools/microWakeWord"
if [ ! -d "${MWW}" ] || [ -n "$(git -C "${MWW}" status --porcelain 2>/dev/null || true)" ] ; then
rm -rf "${MWW}" || :
mkdir -p "${DATA_DIR}/tools"
echo " Cloning micro-wake-word to ${DATA_DIR}/tools"
git clone https://github.com/TaterTotterson/micro-wake-word "${MWW}" &>/dev/null
fi
echo " Installing microwakeword into Blackwell venv"
pip_install --no-deps -e "${MWW}"
echo " ===== Testing Blackwell TensorFlow environment ====="
"${VENV}/bin/python" - <<'PY'
import tensorflow as tf
from ai_edge_litert.interpreter import Interpreter
from microwakeword.data import FeatureHandler
from microwakeword.inference import Model
print("TensorFlow:", tf.__version__)
print("CUDA build:", tf.test.is_built_with_cuda())
print("GPU:", tf.config.list_physical_devices("GPU"))
print("microWakeWord Blackwell imports available")
PY
touch "${MARKER}"
echo "Blackwell TensorFlow environment ready: ${VENV}"

View File

@@ -45,6 +45,7 @@ download_hf_mit_rirs() {
python - "${HF_RIR_REPO_ID}" "${HF_RIR_API_URL}" "${AUDIO_DIR}" <<-'EOF'
import json
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
@@ -67,24 +68,57 @@ files = sorted(
if not files:
raise SystemExit("Hugging Face MIT RIR dataset did not list any 16khz WAV files")
print(f" Found {len(files)} MIT environmental RIR files on Hugging Face mirror", flush=True)
downloaded = 0
skipped = 0
for rel in files:
def download_file(url: str, target: Path, rel: str):
tmp = target.with_suffix(target.suffix + ".incomplete")
for attempt in range(1, 4):
try:
if tmp.exists():
tmp.unlink()
with urllib.request.urlopen(url, timeout=30) as response:
with tmp.open("wb") as out:
while True:
chunk = response.read(1024 * 64)
if not chunk:
break
out.write(chunk)
if not tmp.exists() or tmp.stat().st_size == 0:
raise RuntimeError("empty download")
tmp.replace(target)
return
except Exception as exc:
if tmp.exists():
tmp.unlink()
if attempt == 3:
raise RuntimeError(f"download failed for {rel}: {exc}") from exc
print(f" Retry {attempt}/2 for {rel}: {exc}", flush=True)
time.sleep(2 * attempt)
total = len(files)
for idx, rel in enumerate(files, start=1):
target = audio_dir / rel
if target.exists() and target.stat().st_size > 0:
skipped += 1
if idx == 1 or idx % 25 == 0 or idx == total:
print(f" MIT RIR download progress: {idx}/{total} files ({downloaded} downloaded, {skipped} reused)", flush=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
encoded = urllib.parse.quote(rel, safe="/")
url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{encoded}"
with urllib.request.urlopen(url, timeout=60) as response:
target.write_bytes(response.read())
if idx == 1 or idx % 25 == 0 or idx == total:
print(f" Downloading MIT RIR {idx}/{total}: {rel}", flush=True)
download_file(url, target, rel)
if not target.exists() or target.stat().st_size == 0:
raise SystemExit(f"download failed for {rel}")
downloaded += 1
if idx == 1 or idx % 25 == 0 or idx == total:
print(f" MIT RIR download progress: {idx}/{total} files ({downloaded} downloaded, {skipped} reused)", flush=True)
print(f" Hugging Face MIT environmental RIR download complete ({downloaded} downloaded, {skipped} reused)")
print(f" MIT environmental RIR files available: {len(files)}")
print(f" Hugging Face MIT environmental RIR download complete ({downloaded} downloaded, {skipped} reused)", flush=True)
print(f" MIT environmental RIR files available: {len(files)}", flush=True)
EOF
}

View File

@@ -84,6 +84,21 @@ if [ "${IS_BLACKWELL}" = "true" ]; then
echo " Using GPU compatibility retries; CPU fallback is ${ALLOW_CPU_FALLBACK} (override with MWW_ALLOW_CPU_FALLBACK=true|false)."
fi
BLACKWELL_TF_MODE="${MWW_BLACKWELL_TF:-auto}"
BLACKWELL_TF_REQUIRED="false"
BLACKWELL_TF_ACTIVE="false"
case "${BLACKWELL_TF_MODE,,}" in
1|true|yes|on|required)
BLACKWELL_TF_REQUIRED="true"
;;
0|false|no|off|disabled)
BLACKWELL_TF_MODE="disabled"
;;
*)
BLACKWELL_TF_MODE="auto"
;;
esac
# Enable driver-side PTX JIT fallback when ptxas/nvlink are unavailable.
if [ -z "${XLA_FLAGS:-}" ]; then
export XLA_FLAGS="--xla_gpu_unsafe_fallback_to_driver_on_ptxas_not_found"
@@ -238,6 +253,32 @@ fi
echo " Wrote training_parameters.yaml"
rm -rf "${WORK_DIR}/trained_models/wakeword"
if [ "${IS_BLACKWELL}" = "true" ] && [ "${BLACKWELL_TF_MODE}" != "disabled" ]; then
BLACKWELL_SETUP="${PROGDIR}/setup_blackwell_venv"
BLACKWELL_PYTHON="${DATA_DIR}/.venv-blackwell/bin/python"
if [ -x "${BLACKWELL_SETUP}" ] && command -v python3.13 >/dev/null 2>&1; then
echo "↪️ Preparing Blackwell-native TensorFlow training environment."
if "${BLACKWELL_SETUP}" --data-dir="${DATA_DIR}"; then
PYTHON_BIN="${BLACKWELL_PYTHON}"
BLACKWELL_TF_ACTIVE="true"
echo "✅ Blackwell TensorFlow training enabled: ${PYTHON_BIN}"
else
if [ "${BLACKWELL_TF_REQUIRED}" = "true" ]; then
echo "❌ Blackwell TensorFlow setup failed and MWW_BLACKWELL_TF is required." >&2
exit 1
fi
echo "⚠️ Blackwell TensorFlow setup failed; continuing with compatibility retries."
fi
else
if [ "${BLACKWELL_TF_REQUIRED}" = "true" ]; then
echo "❌ Blackwell TensorFlow was required, but python3.13/setup_blackwell_venv is unavailable." >&2
exit 1
fi
echo " Blackwell TensorFlow image support not available; continuing with compatibility retries."
fi
fi
wake_word_filename="$(
echo "${WAKE_WORD}" \
| tr '[:upper:]' '[:lower:]' \
@@ -456,6 +497,7 @@ calibration_path = Path(os.environ.get("CALIBRATION_PATH", ""))
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
probability_cutoff = 0.97
sliding_window_size = 5
calibration = {}
if calibration_path.exists():
try:
@@ -469,21 +511,60 @@ if calibration_path.exists():
except Exception as exc:
print(f"⚠️ Failed to read detector calibration ({exc}); using defaults.")
probability_cutoff = round(probability_cutoff, 3)
sliding_window_size = max(1, min(10, int(sliding_window_size)))
selected_metrics = calibration.get("selected_metrics") if isinstance(calibration.get("selected_metrics"), dict) else {}
evaluation = calibration.get("evaluation") if isinstance(calibration.get("evaluation"), dict) else {}
close_miss_threshold = max(0.01, min(0.99, round(max(0.01, probability_cutoff - 0.19), 3)))
meta = {
"type": "micro",
"wake_word": os.environ["WAKE_WORD_TITLE"],
"label": os.environ["WAKE_WORD_TITLE"].replace("_", " ").title(),
"author": "Tater Totterson",
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
"model": os.environ["TFLITE_FILENAME"],
"trained_languages": [language],
"version": 2,
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"micro": {
"probability_cutoff": round(probability_cutoff, 2),
"probability_cutoff": probability_cutoff,
"sliding_window_size": sliding_window_size,
"feature_step_size": 10,
"tensor_arena_size": 30000,
"minimum_esphome_version": "2024.7.0",
},
"tater_native": {
"format_version": 1,
"wake_threshold": probability_cutoff,
"wake_sliding_window": sliding_window_size,
"close_miss_threshold": close_miss_threshold,
"frontend": {
"name": "tflm_microfrontend",
"sample_rate": 16000,
"feature_duration_ms": 30,
"feature_step_ms": 10,
"feature_size": 40,
"input_feature_frames": 2,
"lower_band_limit": 125.0,
"upper_band_limit": 7500.0,
},
"recommended_for": ["tater-native-satellite", "voice-pe"],
},
"calibration": {
"target_false_accepts_per_hour": calibration.get("target_false_accepts_per_hour"),
"selected_false_accepts_per_hour_limit": calibration.get("selected_false_accepts_per_hour_limit"),
"recall": selected_metrics.get("recall"),
"false_accepts_per_hour": selected_metrics.get("false_accepts_per_hour"),
"ambient_hours": selected_metrics.get("ambient_hours"),
"positive_dataset": evaluation.get("positive_dataset"),
"ambient_dataset": evaluation.get("ambient_dataset"),
"positive_tracks": evaluation.get("positive_tracks"),
"ambient_tracks": evaluation.get("ambient_tracks"),
"generated_at": calibration.get("generated_at"),
},
}
json_path.write_text(json.dumps(meta, indent=4) + "\n", encoding="utf-8")
PY

54
dockerfile.blackwell Normal file
View File

@@ -0,0 +1,54 @@
# RTX 50 / Blackwell image
FROM nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV CUDA_HOME=/usr/local/cuda
ENV PATH=/usr/local/cuda/bin:${PATH}
ENV LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH}
ENV MWW_BLACKWELL_IMAGE=1
ENV MWW_BLACKWELL_TF=auto
ENV MWW_BLACKWELL_TF_WHEEL_URL=https://github.com/chivitiH/tensorflow-blackwell-python313/releases/download/v2.22.0-selfbuilt/tensorflow-2.22.0.dev0+selfbuilt-cp313-cp313-linux_x86_64.whl
# System deps. Python 3.12 remains the main trainer/runtime venv, while
# Python 3.13 is used only for the Blackwell TensorFlow training step.
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl git wget unzip patch \
ninja-build nano less \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
python3.12 python3.12-venv python3.12-dev \
python3.13 python3.13-venv python3.13-dev \
python3-pip python-is-python3 \
&& ldconfig \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data
# Trainer UI port
EXPOSE 8789
# Script root
WORKDIR /root/mww-scripts
# Bash environment
COPY --chown=root:root --chmod=0755 .bashrc /root/
# Root-level entrypoints
COPY --chown=root:root --chmod=0755 \
train_wake_word \
run.sh \
trainer_server.py \
requirements.txt \
/root/mww-scripts/
# CLI folder
COPY --chown=root:root cli/ /root/mww-scripts/cli/
# Make all CLI scripts executable (avoids "Permission denied")
RUN chmod -R a+x /root/mww-scripts/cli
# Static UI for trainer
COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html
# trainer server
CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"]

View File

@@ -262,6 +262,23 @@
margin-bottom: 0;
}
.firmwareTaterOnlyNotice {
display: flex;
align-items: center;
gap: 12px;
border-color: rgba(255,138,42,0.34);
background:
linear-gradient(135deg, rgba(255,138,42,0.15), rgba(255,255,255,0.035)),
var(--panel2);
color: var(--text);
font-size: 14px;
}
.firmwareTaterOnlyNotice strong {
color: var(--orange2);
white-space: nowrap;
}
.firmwareSteps {
display: flex;
flex-wrap: wrap;
@@ -362,6 +379,17 @@
white-space: nowrap;
}
.runtimeWakeWordMeta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.runtimeWakeWordMeta .pill {
font-size: 11px;
}
.runtimeWakeWordItem button {
white-space: nowrap;
}
@@ -1283,7 +1311,7 @@
<div class="logo"></div>
<div>
<h1>microWakeWord Trainer Studio</h1>
<p>Train wake words, review captured clips, and flash ESPHome firmware from one local workspace.</p>
<p>Train wake words, review captured clips, and flash Tater Native firmware from one local workspace.</p>
</div>
</div>
</div>
@@ -1526,7 +1554,7 @@
<div>
<div class="firmwareKicker">Firmware Studio</div>
<h3>Prebuilt Tater Firmware Flasher</h3>
<p>Flash only for firmware updates. If this is a new satellite or it is not already on Tater firmware <code>3.0.3</code> or newer, do one USB flash first, then use this tab for fast OTA updates.</p>
<p>Flash only for firmware updates. If this is a new satellite or it is not already on Tater Native Firmware <code>v1</code>, do one USB flash first, then use this tab for fast OTA updates.</p>
<div class="firmwareSteps" aria-label="Firmware flashing steps">
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
<span class="firmwareStepChip"><b>2</b> Select target</span>
@@ -1537,14 +1565,19 @@
</div>
</div>
<div class="card firmwareTaterOnlyNotice">
<strong>Tater only</strong>
<span>These native firmware images connect to Tater. They are not Home Assistant or ESPHome satellite firmware.</span>
</div>
<div class="card runtimeWakeWordCard">
<div class="runtimeWakeWordHeader">
<div class="runtimeWakeWordTitle">
<span class="runtimeWakeWordBadge">3.0.3+</span>
<span class="runtimeWakeWordBadge">v1</span>
<div>
<div class="firmwareKicker">Live Model Switching</div>
<h3>Tater firmware 3.0.3 or higher can swap wake words live</h3>
<p>No reflash is needed after training. Copy a trained wake word JSON URL below and paste it into your satellite's Home Assistant <code>microWakeWord Model URL</code> entity.</p>
<h3>Tater Native satellites can swap wake words live</h3>
<p>No reflash is needed after training. Copy a trained wake word JSON URL below and paste it into the native satellite settings in Tater.</p>
</div>
</div>
<span class="pill ok">No reflash needed</span>
@@ -1561,7 +1594,7 @@
<span class="firmwareStepBadge">1</span>
<div>
<h3>Firmware Image</h3>
<p>Choose a prebuilt Tater firmware image from the shared firmware repo.</p>
<p>Choose a prebuilt Tater firmware image from the Tater Native Firmware releases.</p>
</div>
</div>
</div>
@@ -1585,13 +1618,14 @@
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
</div>
<div class="row">
<button id="refreshFirmwareBtn" type="button">Auto-detect ESPHome devices</button>
<button id="refreshFirmwareBtn" type="button">Auto-detect Tater satellites</button>
</div>
<div class="firmwareTargetGrid">
<label class="field">
<strong>Detected Device</strong>
<select id="firmwareDeviceSelect">
<option value="">No devices scanned yet</option>
<option value="">Choose target...</option>
<option value="__browser_usb_flash__">Browser USB Flash (new/recovery)</option>
</select>
</label>
<label class="field">
@@ -1623,7 +1657,7 @@
<button id="cleanFirmwareBtn" type="button">Clear downloaded images</button>
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
</div>
<div class="usbFlashHint">Use Browser USB Flash for new satellites, recovery, or devices older than Tater firmware 3.0.3. Chrome or Edge must be used on localhost or HTTPS. In the macOS app, open <code>http://127.0.0.1:8789</code> in Chrome or Edge; port <code>3232</code> is only for OTA updates after firmware is installed.</div>
<div class="usbFlashHint">Use Browser USB Flash for new satellites, recovery, or devices not already running Tater Native Firmware v1. After USB flash, join the satellite setup hotspot and finish Wi-Fi, Tater server, and pairing in the device setup page. Chrome or Edge must be used on localhost or HTTPS.</div>
</section>
</div>
</div>
@@ -2031,7 +2065,7 @@
}
function escapeHtml(text) {
return String(text || "")
return String(text ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
@@ -2056,6 +2090,12 @@
return { label: item.capture_label || "Captured", cls: "" };
}
function formatDetectionProfile(value) {
return String(value || "")
.replaceAll("_", " ")
.replace(/\b\w/g, (ch) => ch.toUpperCase());
}
function renderCapturedItems(payload) {
const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 };
uiState.captured = data;
@@ -2084,6 +2124,18 @@
if (item.wake_word) meta.push(`<span class="pill">${escapeHtml(item.wake_word)}</span>`);
if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`<span class="pill">max ${escapeHtml(item.max_probability)}</span>`);
if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`<span class="pill">avg ${escapeHtml(item.average_probability)}</span>`);
if (item.detection_profile) meta.push(`<span class="pill">profile ${escapeHtml(formatDetectionProfile(item.detection_profile))}</span>`);
if (item.peak_probability_cutoff !== null && item.peak_probability_cutoff !== undefined) meta.push(`<span class="pill">peak cutoff ${escapeHtml(item.peak_probability_cutoff)}</span>`);
if (item.probability_cutoff !== null && item.probability_cutoff !== undefined) meta.push(`<span class="pill">avg cutoff ${escapeHtml(item.probability_cutoff)}</span>`);
if (item.active_window_count !== null && item.active_window_count !== undefined && item.min_active_windows !== null && item.min_active_windows !== undefined) {
meta.push(`<span class="pill">windows ${escapeHtml(item.active_window_count)}/${escapeHtml(item.min_active_windows)}</span>`);
}
if (item.rise_score !== null && item.rise_score !== undefined) meta.push(`<span class="pill">rise ${escapeHtml(item.rise_score)}</span>`);
if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) meta.push(`<span class="pill">VAD max ${escapeHtml(item.vad_max_probability)}</span>`);
if (item.vad_average_probability !== null && item.vad_average_probability !== undefined) meta.push(`<span class="pill">VAD avg ${escapeHtml(item.vad_average_probability)}</span>`);
if (Array.isArray(item.probability_history) && item.probability_history.length) {
meta.push(`<span class="pill" title="${escapeAttr(item.probability_history.join(", "))}">history ${escapeHtml(item.probability_history.length)}</span>`);
}
const formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit";
const when = formatTimestamp(item.captured_at || item.received_at);
const actionDisabled = uiState.reviewBusy ? "disabled" : "";
@@ -2441,7 +2493,7 @@
async function browserUsbSelectPort(selector = "") {
if (!window.isSecureContext || !navigator.serial) {
throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. Open the trainer URL in Chrome or Edge.");
}
const port = await navigator.serial.requestPort();
const key = String(selector || "default").trim() || "default";
@@ -2449,6 +2501,11 @@
return port;
}
function browserUsbStoredPort(selector = "") {
const key = String(selector || "default").trim() || "default";
return uiState.browserUsb.ports?.[key] || null;
}
function normalizeEsptoolJsModule(module) {
if (module?.ESPLoader && module?.Transport) {
return module;
@@ -2507,19 +2564,15 @@
return chunks.join("");
}
async function browserUsbSetSignals(port, signals, timeoutMs = 1200) {
if (!port || typeof port.setSignals !== "function") {
return false;
}
async function browserUsbWithTimeout(promise, timeoutMs, message) {
let timeoutId = 0;
try {
await Promise.race([
port.setSignals(signals),
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error("Timed out setting USB serial control signals.")), Math.max(1, Number(timeoutMs) || 1));
timeoutId = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1));
}),
]);
return true;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
@@ -2527,6 +2580,18 @@
}
}
async function browserUsbSetSignals(port, signals, timeoutMs = 1200) {
if (!port || typeof port.setSignals !== "function") {
return false;
}
await browserUsbWithTimeout(
port.setSignals(signals),
timeoutMs,
"Timed out setting USB serial control signals."
);
return true;
}
async function browserUsbHardResetAfterFlash(transport, loader, port) {
try {
appendFirmwareLog("Resetting device after USB flash.");
@@ -2648,26 +2713,39 @@
: "No files selected";
}
const FIRMWARE_USB_DEVICE_VALUE = "__browser_usb_flash__";
function firmwareUsbSelected() {
return $("firmwareDeviceSelect").value === FIRMWARE_USB_DEVICE_VALUE;
}
function renderFirmwareDevices(devices, message) {
const list = Array.isArray(devices) ? devices : [];
uiState.firmware.devices = list;
if (!list.length) {
$("firmwareDeviceSelect").innerHTML = `<option value="">No devices detected</option>`;
setPill($("firmwareDetectStatus"), message || "No devices detected", "warn");
return;
}
$("firmwareDeviceSelect").innerHTML = [
`<option value="">Choose detected device...</option>`,
`<option value="">Choose target...</option>`,
`<option value="${FIRMWARE_USB_DEVICE_VALUE}">Browser USB Flash (new/recovery)</option>`,
...list.map((device, index) => {
const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`;
return `<option value="${index}">${escapeHtml(label)}</option>`;
}),
].join("");
if (!list.length) {
setPill($("firmwareDetectStatus"), message || "No devices detected", "warn");
if (!($("firmwareHost").value || "").trim()) {
$("firmwareDeviceSelect").value = FIRMWARE_USB_DEVICE_VALUE;
applySelectedFirmwareDevice().catch((error) => {
setPill($("firmwareStatus"), "USB settings failed", "warn");
console.warn("USB settings load failed", error);
});
}
return;
}
setPill($("firmwareDetectStatus"), `${list.length} detected`, "ok");
if (!($("firmwareHost").value || "").trim()) {
if (!($("firmwareHost").value || "").trim() && !firmwareUsbSelected()) {
$("firmwareDeviceSelect").value = "0";
applySelectedFirmwareDevice().catch((error) => {
setPill($("firmwareStatus"), "Device settings failed", "warn");
@@ -2687,6 +2765,14 @@
async function applySelectedFirmwareDevice() {
const indexText = $("firmwareDeviceSelect").value;
if (indexText === "") return;
if (indexText === FIRMWARE_USB_DEVICE_VALUE) {
$("firmwareHost").value = "";
$("firmwarePort").value = "3232";
setPill($("firmwareStatus"), "Browser USB selected", "ok");
await refreshFirmwareTemplates();
syncButtons();
return;
}
const device = uiState.firmware.devices[Number(indexText)];
if (!device) return;
await flushFirmwareProfileSave();
@@ -2711,6 +2797,7 @@
function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) {
if (!template) return;
if (firmwareUsbSelected()) return;
if (template.target_host) {
$("firmwareHost").value = template.target_host;
}
@@ -2749,6 +2836,13 @@
.map((item) => ({
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
url: String(item.json_url || "").trim(),
threshold: item.threshold,
slidingWindow: item.sliding_window,
closeMissThreshold: item.close_miss_threshold,
quantization: String(item.quantization || "").trim(),
modelFormat: String(item.model_format || "").trim(),
recall: item.calibration_recall,
falseAcceptsPerHour: item.calibration_false_accepts_per_hour,
}))
.filter((item) => item.url);
@@ -2757,15 +2851,27 @@
return;
}
container.innerHTML = rows.map((item) => `
container.innerHTML = rows.map((item) => {
const meta = [];
if (item.threshold !== null && item.threshold !== undefined) meta.push(`<span class="pill">threshold ${escapeHtml(item.threshold)}</span>`);
if (item.slidingWindow !== null && item.slidingWindow !== undefined) meta.push(`<span class="pill">window ${escapeHtml(item.slidingWindow)}</span>`);
if (item.closeMissThreshold !== null && item.closeMissThreshold !== undefined) meta.push(`<span class="pill">close miss ${escapeHtml(item.closeMissThreshold)}</span>`);
if (item.quantization) meta.push(`<span class="pill">${escapeHtml(item.quantization)}</span>`);
const recall = Number(item.recall);
const faph = Number(item.falseAcceptsPerHour);
if (Number.isFinite(recall)) meta.push(`<span class="pill">recall ${(recall * 100).toFixed(1)}%</span>`);
if (Number.isFinite(faph)) meta.push(`<span class="pill">FA/h ${escapeHtml(faph)}</span>`);
return `
<div class="runtimeWakeWordItem">
<div>
<strong>${escapeHtml(item.label)}</strong>
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
${meta.length ? `<div class="runtimeWakeWordMeta">${meta.join("")}</div>` : ""}
</div>
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
</div>
`).join("");
`;
}).join("");
}
async function copyTextToClipboard(text) {
@@ -3023,6 +3129,9 @@
function firmwareTemplateQuery() {
const params = new URLSearchParams();
if (firmwareUsbSelected()) {
return "";
}
const host = ($("firmwareHost").value || "").trim();
const port = ($("firmwarePort").value || "3232").trim();
if (host) params.set("target_host", host);
@@ -3143,7 +3252,7 @@
syncRenderedWakeSoundSelection({ fromPicker: true });
}
const ok = confirm(`Flash prebuilt ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nOnly continue if this device is already running Tater firmware 3.0.3 or newer. New devices need one USB flash first.`);
const ok = confirm(`Flash prebuilt ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nOnly continue if this device is already running Tater Native Firmware v1. New devices need one USB flash first.`);
if (!ok) return;
uiState.firmwareBusy = true;
@@ -3197,15 +3306,15 @@
return;
}
if (!window.isSecureContext || !navigator.serial) {
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. Open the trainer URL in Chrome or Edge.");
return;
}
const ok = confirm(`USB flash the prebuilt ${template.label || template.value} factory firmware?\n\nThis is for new satellites, recovery, or devices older than Tater firmware 3.0.3. It erases flash and writes the factory image over USB.`);
const ok = confirm(`USB flash the prebuilt ${template.label || template.value} factory firmware?\n\nThis is for new satellites, recovery, or devices not already running Tater Native Firmware v1. It erases flash and writes the factory image over USB. After flashing, finish Wi-Fi and pairing from the satellite setup page.`);
if (!ok) return;
let port;
try {
port = await browserUsbSelectPort(template.value);
port = browserUsbStoredPort(template.value) || await browserUsbSelectPort(template.value);
} catch (error) {
alert("USB device selection failed: " + error.message);
return;
@@ -3234,6 +3343,7 @@
appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash...");
await flashBrowserUsbPort(port, artifact);
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
appendFirmwareLog("Use the satellite setup hotspot to configure Wi-Fi, Tater server, and pairing.");
setPill($("firmwareStatus"), "USB flash finished", "ok");
} catch (error) {
appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed.");
@@ -3320,6 +3430,7 @@
const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
const firmwareSelection = selectedFirmwareTemplate();
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available);
const usbSelected = firmwareUsbSelected();
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
@@ -3331,10 +3442,10 @@
$("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0;
$("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy;
$("refreshFirmwareBtn").disabled = uiState.firmwareBusy;
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate;
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate;
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
$("openFirmwareConsoleBtn").disabled = false;
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
$("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path;
}

View File

@@ -20,7 +20,7 @@ import uuid
import wave
from array import array
from datetime import datetime, timezone
from math import log10
from math import isfinite, log10
from pathlib import Path
from typing import Dict, Any, List, Callable, Optional, Tuple
from urllib.parse import quote, urlparse
@@ -80,12 +80,17 @@ CAPTURE_GAIN_PROFILE = "capture_rms_v1"
# Firmware build/flash cache lives inside /data so Docker runs can reuse downloads.
FIRMWARE_CACHE_DIR = Path(os.environ.get("FIRMWARE_CACHE_DIR", str(DATA_DIR / ".cache" / "firmware_flasher"))).resolve()
FIRMWARE_DEFAULT_OTA_PORT = int(os.environ.get("ESPHOME_OTA_PORT", "3232"))
FIRMWARE_DISCOVERY_SECONDS = float(os.environ.get("ESPHOME_DISCOVERY_SECONDS", "2.5"))
FIRMWARE_DEFAULT_OTA_PORT = int(os.environ.get("TATER_NATIVE_OTA_PORT", os.environ.get("ESPHOME_OTA_PORT", "3232")))
FIRMWARE_DISCOVERY_SECONDS = float(
os.environ.get("TATER_NATIVE_DISCOVERY_SECONDS", os.environ.get("ESPHOME_DISCOVERY_SECONDS", "2.5"))
)
FIRMWARE_MAX_LOG_LINES = int(os.environ.get("FIRMWARE_MAX_LOG_LINES", "500"))
FIRMWARE_GITHUB_OWNER = os.environ.get("FIRMWARE_GITHUB_OWNER", "TaterTotterson")
FIRMWARE_GITHUB_REPO = os.environ.get("FIRMWARE_GITHUB_REPO", "microWakeWords")
FIRMWARE_GITHUB_REF = os.environ.get("FIRMWARE_GITHUB_REF", "main")
FIRMWARE_PREBUILT_GITHUB_OWNER = os.environ.get("FIRMWARE_PREBUILT_GITHUB_OWNER", "TaterTotterson")
FIRMWARE_PREBUILT_GITHUB_REPO = os.environ.get("FIRMWARE_PREBUILT_GITHUB_REPO", "Tater-Native-Firmware")
FIRMWARE_PREBUILT_GITHUB_REF = os.environ.get("FIRMWARE_PREBUILT_GITHUB_REF", "main")
WAKE_SOUND_CATALOG_CACHE_TTL_SECONDS = int(os.environ.get("WAKE_SOUND_CATALOG_CACHE_TTL_SECONDS", "600"))
FIRMWARE_PREBUILT_DIR = FIRMWARE_CACHE_DIR / "prebuilt_firmware"
FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS = float(os.environ.get("FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS", "120"))
@@ -106,32 +111,32 @@ TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 10
FIRMWARE_TEMPLATE_SPECS = (
{
"key": "voicepe",
"label": "VoicePE",
"description": "VoicePE satellite prebuilt firmware",
"label": "Voice PE",
"description": "Tater Native firmware for Voice PE satellites",
"flash_size": "16MB",
},
{
"key": "satellite1",
"label": "Sat1",
"description": "Satellite1 prebuilt firmware",
},
{
"key": "respeaker_lite",
"label": "ReSpeaker Lite",
"description": "ReSpeaker Lite prebuilt firmware",
},
{
"key": "koala",
"label": "Koala Satellite",
"description": "Koala satellite prebuilt firmware",
"label": "Satellite1",
"description": "Tater Native firmware for Satellite1 devices",
"flash_size": "16MB",
},
{
"key": "respeaker_xvf3800",
"label": "ReSpeaker XVF3800",
"description": "ReSpeaker XVF3800 prebuilt firmware",
"description": "Tater Native firmware for ReSpeaker XVF3800 devices",
"flash_size": "8MB",
},
{
"key": "s3_box",
"label": "ESP32-S3-BOX-3 Display",
"description": "Tater Native firmware for ESP32-S3-BOX-3 display satellites",
"flash_size": "16MB",
},
)
FIRMWARE_PREBUILT_LATEST_URL = (
f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/prebuilt_firmware/latest.json"
FIRMWARE_PREBUILT_LATEST_URL = os.environ.get(
"FIRMWARE_PREBUILT_LATEST_URL",
f"https://github.com/{FIRMWARE_PREBUILT_GITHUB_OWNER}/{FIRMWARE_PREBUILT_GITHUB_REPO}/releases/latest/download/latest.json",
)
FIRMWARE_PREBUILT_TEMPLATE_KEYS = {str(spec.get("key") or "").lower() for spec in FIRMWARE_TEMPLATE_SPECS}
@@ -308,10 +313,27 @@ def _sync_trained_wake_word_artifacts() -> None:
tflite_path.unlink()
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
def _metadata_float(value: Any) -> float | None:
try:
out = float(value)
except (TypeError, ValueError):
return None
if not isfinite(out):
return None
return out
def _metadata_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
_sync_trained_wake_word_artifacts()
base = str(base_url or "").rstrip("/")
rows: List[Dict[str, str]] = []
rows: List[Dict[str, Any]] = []
seen: set[str] = set()
for json_path in sorted(TRAINED_WAKE_WORDS_DIR.glob("*.json")):
@@ -333,6 +355,18 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
seen.add(safe)
wake_word = str(meta.get("wake_word") or safe.replace("_", " ")).strip()
micro = meta.get("micro") if isinstance(meta.get("micro"), dict) else {}
native = meta.get("tater_native") if isinstance(meta.get("tater_native"), dict) else {}
calibration = meta.get("calibration") if isinstance(meta.get("calibration"), dict) else {}
threshold = _metadata_float(native.get("wake_threshold"))
if threshold is None:
threshold = _metadata_float(micro.get("probability_cutoff"))
sliding_window = _metadata_int(native.get("wake_sliding_window"))
if sliding_window is None:
sliding_window = _metadata_int(micro.get("sliding_window_size"))
close_miss_threshold = _metadata_float(native.get("close_miss_threshold"))
recall = _metadata_float(calibration.get("recall"))
false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour"))
json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
if base:
@@ -349,6 +383,17 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
"model_url": model_url,
"json_file": json_path.name,
"model_file": model_path.name,
"threshold": round(threshold, 3) if threshold is not None else None,
"sliding_window": sliding_window,
"close_miss_threshold": round(close_miss_threshold, 3) if close_miss_threshold is not None else None,
"quantization": str(meta.get("quantization") or "").strip(),
"model_format": str(meta.get("model_format") or "").strip(),
"sample_rate": _metadata_int(meta.get("sample_rate")),
"calibration_recall": round(recall, 4) if recall is not None else None,
"calibration_false_accepts_per_hour": (
round(false_accepts_per_hour, 6) if false_accepts_per_hour is not None else None
),
"calibration_generated_at": str(calibration.get("generated_at") or "").strip(),
}
)
return rows
@@ -684,6 +729,30 @@ def _parse_float(value: Any) -> float | None:
return None
def _parse_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(float(value))
except Exception:
return None
def _parse_probability_history(value: Any) -> List[int]:
if value in (None, ""):
return []
if isinstance(value, list):
raw_values = value
else:
raw_values = str(value).split(",")
history: List[int] = []
for raw_value in raw_values:
parsed = _parse_int(raw_value)
if parsed is not None:
history.append(parsed)
return history
def _audio_sidecar_path(audio_path: Path) -> Path:
return audio_path.with_suffix(".json")
@@ -1001,6 +1070,15 @@ def _captured_item_from_path(audio_path: Path) -> Dict[str, Any]:
"blocked_by_vad": bool(meta.get("blocked_by_vad")),
"max_probability": meta.get("max_probability"),
"average_probability": meta.get("average_probability"),
"probability_cutoff": meta.get("probability_cutoff"),
"peak_probability_cutoff": meta.get("peak_probability_cutoff"),
"active_window_count": meta.get("active_window_count"),
"min_active_windows": meta.get("min_active_windows"),
"rise_score": meta.get("rise_score"),
"vad_max_probability": meta.get("vad_max_probability"),
"vad_average_probability": meta.get("vad_average_probability"),
"detection_profile": meta.get("detection_profile") or "",
"probability_history": meta.get("probability_history") or [],
"detected_format": meta.get("detected_format") or {},
"final_format": final_format,
"postprocess": meta.get("postprocess") or {},
@@ -1453,6 +1531,15 @@ def _firmware_template_spec(template_key: str) -> Dict[str, Any]:
raise ValueError("Unknown firmware template.")
def _firmware_template_flash_size(template_key: Any) -> str:
try:
spec = _firmware_template_spec(_text(template_key))
except Exception:
spec = {}
flash_size = _text(spec.get("flash_size")).upper()
return flash_size if flash_size in {"4MB", "8MB", "16MB", "32MB"} else "8MB"
def _firmware_raw_url(path: str) -> str:
clean = str(path or "").strip().lstrip("/")
return f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/{clean}"
@@ -1499,7 +1586,10 @@ def _prebuilt_firmware_raw_url(path_or_url: Any) -> str:
return token
clean = token.lstrip("/")
quoted = "/".join(quote(part) for part in clean.split("/") if part)
return f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/{quoted}"
return (
f"https://raw.githubusercontent.com/"
f"{FIRMWARE_PREBUILT_GITHUB_OWNER}/{FIRMWARE_PREBUILT_GITHUB_REPO}/{FIRMWARE_PREBUILT_GITHUB_REF}/{quoted}"
)
def _fetch_json_url(url: str, *, timeout: float = 20, force_refresh: bool = False) -> Dict[str, Any]:
@@ -1713,7 +1803,7 @@ def _create_browser_flash_artifact(template_key: Any, prebuilt: Dict[str, Any],
"source_binary": str(binary_path),
"binary_size": int(target_binary_path.stat().st_size),
"erase_all": True,
"flash_size": "4MB",
"flash_size": _firmware_template_flash_size(template_key),
"flash_mode": "dio",
"flash_freq": "40m",
}
@@ -2666,7 +2756,7 @@ def _dedupe_discovered_devices(devices: List[Dict[str, Any]]) -> List[Dict[str,
clean_devices: List[Dict[str, Any]] = []
for item in devices:
host = str(item.get("host") or "").strip()
name = str(item.get("name") or host or "ESPHome device").strip()
name = str(item.get("name") or host or "Tater satellite").strip()
if not host:
continue
key = (host.lower(), int(item.get("port") or FIRMWARE_DEFAULT_OTA_PORT))
@@ -2782,13 +2872,13 @@ def _discover_with_dns_sd(timeout_seconds: float) -> List[Dict[str, Any]]:
def _discover_esphome_devices() -> tuple[List[Dict[str, Any]], str]:
devices = _discover_with_zeroconf(FIRMWARE_DISCOVERY_SECONDS)
if devices:
return devices, f"Found {len(devices)} ESPHome device{'' if len(devices) == 1 else 's'} with mDNS."
return devices, f"Found {len(devices)} Tater native satellite{'' if len(devices) == 1 else 's'} with mDNS."
devices = _discover_with_dns_sd(FIRMWARE_DISCOVERY_SECONDS)
if devices:
return devices, f"Found {len(devices)} ESPHome device{'' if len(devices) == 1 else 's'} with dns-sd."
return devices, f"Found {len(devices)} Tater native satellite{'' if len(devices) == 1 else 's'} with dns-sd."
return [], "No ESPHome devices were auto-detected. Enter the device IP or hostname manually."
return [], "No Tater native satellites were auto-detected. Enter the device IP or hostname manually."
# -------------------- Routes --------------------
@@ -2967,6 +3057,15 @@ async def upload_captured_audio(
"average_probability": _parse_float(
extra_meta.get("average_probability") if average_probability is None else average_probability
),
"probability_cutoff": _parse_int(extra_meta.get("probability_cutoff")),
"peak_probability_cutoff": _parse_int(extra_meta.get("peak_probability_cutoff")),
"active_window_count": _parse_int(extra_meta.get("active_window_count")),
"min_active_windows": _parse_int(extra_meta.get("min_active_windows")),
"rise_score": _parse_int(extra_meta.get("rise_score")),
"vad_max_probability": _parse_int(extra_meta.get("vad_max_probability")),
"vad_average_probability": _parse_int(extra_meta.get("vad_average_probability")),
"detection_profile": str(extra_meta.get("detection_profile") or "").strip(),
"probability_history": _parse_probability_history(extra_meta.get("probability_history")),
"notes": notes or extra_meta.get("notes") or "",
"converted": result["converted"],
"detected_format": result["detected_format"],
@@ -2996,6 +3095,15 @@ async def upload_captured_audio_raw(
x_blocked_by_vad: str | None = Header(default=None),
x_max_probability: str | None = Header(default=None),
x_average_probability: str | None = Header(default=None),
x_probability_cutoff: str | None = Header(default=None),
x_peak_probability_cutoff: str | None = Header(default=None),
x_active_windows: str | None = Header(default=None),
x_min_active_windows: str | None = Header(default=None),
x_rise_score: str | None = Header(default=None),
x_vad_max_probability: str | None = Header(default=None),
x_vad_average_probability: str | None = Header(default=None),
x_detection_profile: str | None = Header(default=None),
x_probability_history: str | None = Header(default=None),
x_notes: str | None = Header(default=None),
):
raw_data = await request.body()
@@ -3031,6 +3139,15 @@ async def upload_captured_audio_raw(
"blocked_by_vad": _parse_bool(x_blocked_by_vad),
"max_probability": _parse_float(x_max_probability),
"average_probability": _parse_float(x_average_probability),
"probability_cutoff": _parse_int(x_probability_cutoff),
"peak_probability_cutoff": _parse_int(x_peak_probability_cutoff),
"active_window_count": _parse_int(x_active_windows),
"min_active_windows": _parse_int(x_min_active_windows),
"rise_score": _parse_int(x_rise_score),
"vad_max_probability": _parse_int(x_vad_max_probability),
"vad_average_probability": _parse_int(x_vad_average_probability),
"detection_profile": (x_detection_profile or "").strip(),
"probability_history": _parse_probability_history(x_probability_history),
"notes": x_notes or "",
"converted": result["converted"],
"detected_format": result["detected_format"],