mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 16:05:34 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b22200e0 | ||
|
|
89260f1f14 | ||
|
|
0140dfb56f | ||
|
|
1fc7d80bae | ||
|
|
31a6388da4 | ||
|
|
85c2d6334b | ||
|
|
5f6f108c85 |
70
.github/workflows/docker-publish.yml
vendored
70
.github/workflows/docker-publish.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -41,6 +41,8 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
@@ -56,3 +58,69 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha,scope=mww-trainer-nvidia-docker
|
cache-from: type=gha,scope=mww-trainer-nvidia-docker
|
||||||
cache-to: type=gha,mode=max,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
|
||||||
|
|||||||
90
README.md
90
README.md
@@ -22,9 +22,22 @@ docker pull ghcr.io/tatertotterson/microwakeword:latest
|
|||||||
Tagged releases also publish matching immutable image tags:
|
Tagged releases also publish matching immutable image tags:
|
||||||
|
|
||||||
```bash
|
```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
|
## Run The Container
|
||||||
@@ -38,12 +51,15 @@ docker run -d \
|
|||||||
ghcr.io/tatertotterson/microwakeword:latest
|
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:
|
The flags:
|
||||||
|
|
||||||
- `--gpus all` enables GPU acceleration.
|
- `--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.
|
- `-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.
|
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and firmware caches.
|
||||||
|
|
||||||
@@ -55,14 +71,14 @@ Open:
|
|||||||
http://localhost:8789
|
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
|
## What The UI Does
|
||||||
|
|
||||||
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training.
|
- `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.
|
- `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.
|
- `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.
|
- 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
|
## 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 Wake Audio` toggles upload of wake-word triggers.
|
||||||
- `Capture Close Misses` toggles upload of near misses.
|
- `Capture Close Misses` toggles upload of near misses.
|
||||||
- `Trainer App URL` sets the trainer address, for example `http://<trainer-ip>:8789`.
|
- `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
|
```text
|
||||||
/api/upload_captured_audio_raw
|
/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.
|
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
|
## Language Support
|
||||||
@@ -185,18 +209,21 @@ After those assets are prepared, later runs reuse the local copies unless the mo
|
|||||||
|
|
||||||
## Firmware Flashing
|
## 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.
|
- 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.
|
- Allows manual IP or hostname entry if discovery does not find the device.
|
||||||
- Saves the selected OTA target for each firmware family.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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
|
## Resetting Everything
|
||||||
@@ -243,7 +306,7 @@ That removes:
|
|||||||
- Negative samples are optional but useful for reducing false wakes.
|
- Negative samples are optional but useful for reducing false wakes.
|
||||||
- The UI server is `trainer_server.py`.
|
- The UI server is `trainer_server.py`.
|
||||||
- The launcher is `run.sh`.
|
- 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)
|
- [microWakeWord](https://github.com/kahrendt/microWakeWord)
|
||||||
- [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator)
|
- [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
112
cli/setup_blackwell_venv
Executable 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}"
|
||||||
@@ -25,9 +25,9 @@ fi
|
|||||||
mkdir -p "${DATA_DIR}/training_datasets/downloads" || :
|
mkdir -p "${DATA_DIR}/training_datasets/downloads" || :
|
||||||
cd "${DATA_DIR}/training_datasets"
|
cd "${DATA_DIR}/training_datasets"
|
||||||
|
|
||||||
AUDIO_URL="https://mcdermottlab.mit.edu/Reverb/IRMAudio/Audio.zip"
|
HF_RIR_REPO_ID="TaterTotterson/MIT_environmental_impulse_responses"
|
||||||
AUDIO_ZIPFILE="MIT_RIR_Audio.zip"
|
HF_RIR_API_URL="https://huggingface.co/api/datasets/${HF_RIR_REPO_ID}"
|
||||||
AUDIO_ZIP="./downloads/${AUDIO_ZIPFILE}"
|
HF_RIR_SOURCE_KEY="hf_mit_environmental_impulse_responses"
|
||||||
AUDIO_DIR="./mit_rirs"
|
AUDIO_DIR="./mit_rirs"
|
||||||
mkdir -p "${AUDIO_DIR}" || :
|
mkdir -p "${AUDIO_DIR}" || :
|
||||||
AUDIO16K_DIR="./mit_rirs_16k"
|
AUDIO16K_DIR="./mit_rirs_16k"
|
||||||
@@ -35,10 +35,92 @@ mkdir -p "${AUDIO16K_DIR}" || :
|
|||||||
AUDIO_FILECOUNT="./downloads/mit_rir_filecount"
|
AUDIO_FILECOUNT="./downloads/mit_rir_filecount"
|
||||||
AUDIO_IN_GLOB="*.wav"
|
AUDIO_IN_GLOB="*.wav"
|
||||||
|
|
||||||
declare -A filecounts=( [${AUDIO_ZIPFILE}]=0 )
|
declare -A filecounts=( [${HF_RIR_SOURCE_KEY}]=0 )
|
||||||
get_filecounts filecounts "${AUDIO_FILECOUNT}"
|
get_filecounts filecounts "${AUDIO_FILECOUNT}"
|
||||||
|
|
||||||
echo "===== Checking MIT_RIR ====="
|
echo "===== Checking MIT environmental RIRs ====="
|
||||||
|
|
||||||
|
download_hf_mit_rirs() {
|
||||||
|
source ${DATA_DIR}/.venv/bin/activate
|
||||||
|
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
|
||||||
|
|
||||||
|
repo_id = sys.argv[1]
|
||||||
|
api_url = sys.argv[2]
|
||||||
|
audio_dir = Path(sys.argv[3])
|
||||||
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
request = urllib.request.Request(api_url, headers={"User-Agent": "WakeWordTrainer/1.0"})
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
metadata = json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
files = sorted(
|
||||||
|
sibling.get("rfilename", "")
|
||||||
|
for sibling in metadata.get("siblings", [])
|
||||||
|
if str(sibling.get("rfilename", "")).startswith("16khz/")
|
||||||
|
and str(sibling.get("rfilename", "")).lower().endswith(".wav")
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
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}"
|
||||||
|
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)", flush=True)
|
||||||
|
print(f" MIT environmental RIR files available: {len(files)}", flush=True)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
converter() {
|
converter() {
|
||||||
source ${DATA_DIR}/.venv/bin/activate
|
source ${DATA_DIR}/.venv/bin/activate
|
||||||
@@ -58,9 +140,9 @@ rir_out = Path(sys.argv[2])
|
|||||||
|
|
||||||
waves = list(rir_in.rglob("*.wav"))
|
waves = list(rir_in.rglob("*.wav"))
|
||||||
try:
|
try:
|
||||||
print(" MIT RIR normalizing to 16k…")
|
print(" MIT environmental RIR normalizing to 16k…")
|
||||||
# Normalize to 16k mono
|
# Normalize to 16k mono
|
||||||
for p in tqdm(waves, desc=" MIT_RIR (resample 16k mono)"):
|
for p in tqdm(waves, desc=" MIT environmental RIR (resample 16k mono)"):
|
||||||
outfile = Path(rir_out / p.name)
|
outfile = Path(rir_out / p.name)
|
||||||
if outfile.exists():
|
if outfile.exists():
|
||||||
continue
|
continue
|
||||||
@@ -70,14 +152,14 @@ try:
|
|||||||
if sr != 16000:
|
if sr != 16000:
|
||||||
a, _ = librosa.load(p, sr=16000, mono=True)
|
a, _ = librosa.load(p, sr=16000, mono=True)
|
||||||
write_wav(outfile, a, 16000)
|
write_wav(outfile, a, 16000)
|
||||||
print(" MIT RIR normalization complete")
|
print(" MIT environmental RIR normalization complete")
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
print(f" MIT RIR fallback failed: {e2}")
|
print(f" MIT environmental RIR preparation failed: {e2}")
|
||||||
raise
|
raise
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_filecount=${filecounts[${AUDIO_ZIPFILE}]}
|
expected_filecount=${filecounts[${HF_RIR_SOURCE_KEY}]}
|
||||||
actual_filecount=$(find "${AUDIO16K_DIR}" -name '*.wav' 2>/dev/null | wc -l) || :
|
actual_filecount=$(find "${AUDIO16K_DIR}" -name '*.wav' 2>/dev/null | wc -l) || :
|
||||||
write_filecount=false
|
write_filecount=false
|
||||||
|
|
||||||
@@ -85,24 +167,16 @@ if [ "${actual_filecount}" -ne 0 ] && [ "${actual_filecount}" -eq "${expected_fi
|
|||||||
echo " Existing ${AUDIO16K_DIR} valid"
|
echo " Existing ${AUDIO16K_DIR} valid"
|
||||||
else
|
else
|
||||||
actual_filecount=$(find "${AUDIO_DIR}" -name "${AUDIO_IN_GLOB}" 2>/dev/null | wc -l) || :
|
actual_filecount=$(find "${AUDIO_DIR}" -name "${AUDIO_IN_GLOB}" 2>/dev/null | wc -l) || :
|
||||||
if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then
|
if [ "${actual_filecount}" -eq 0 ] || [ "${expected_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then
|
||||||
if [ ! -f "${AUDIO_ZIP}" ] ; then
|
|
||||||
echo " Downloading ${AUDIO_ZIPFILE}"
|
|
||||||
curl -sfL "${AUDIO_URL}" -o "${AUDIO_ZIP}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "${AUDIO_DIR}" || :
|
rm -rf "${AUDIO_DIR}" || :
|
||||||
echo " Unzipping ${AUDIO_ZIPFILE}"
|
mkdir -p "${AUDIO_DIR}" || :
|
||||||
unzip -u -q -d "${AUDIO_DIR}" "${AUDIO_ZIP}"
|
echo " Downloading MIT environmental impulse responses from Hugging Face mirror"
|
||||||
fi
|
download_hf_mit_rirs
|
||||||
if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then
|
|
||||||
echo " Cleaning up ${AUDIO_ZIPFILE}"
|
|
||||||
rm -rf "${AUDIO_ZIP}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
converter
|
converter
|
||||||
actual_filecount=$(find "${AUDIO16K_DIR}" -name "*.wav" 2>/dev/null | wc -l) || :
|
actual_filecount=$(find "${AUDIO16K_DIR}" -name "*.wav" 2>/dev/null | wc -l) || :
|
||||||
filecounts[${AUDIO_ZIPFILE}]="${actual_filecount}"
|
filecounts[${HF_RIR_SOURCE_KEY}]="${actual_filecount}"
|
||||||
write_filecount=true
|
write_filecount=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -110,15 +184,10 @@ if ${write_filecount} ; then
|
|||||||
write_filecounts filecounts "${AUDIO_FILECOUNT}"
|
write_filecounts filecounts "${AUDIO_FILECOUNT}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then
|
|
||||||
echo " Cleaning up ${AUDIO_ZIPFILE}"
|
|
||||||
rm -rf "${AUDIO_ZIP}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ]; then
|
if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ]; then
|
||||||
echo " Cleaning up ${AUDIO_DIR}"
|
echo " Cleaning up ${AUDIO_DIR}"
|
||||||
rm -rf "${AUDIO_DIR}"
|
rm -rf "${AUDIO_DIR}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " MIT_RIR complete"
|
echo " MIT environmental RIRs complete"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -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)."
|
echo "ℹ️ Using GPU compatibility retries; CPU fallback is ${ALLOW_CPU_FALLBACK} (override with MWW_ALLOW_CPU_FALLBACK=true|false)."
|
||||||
fi
|
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.
|
# Enable driver-side PTX JIT fallback when ptxas/nvlink are unavailable.
|
||||||
if [ -z "${XLA_FLAGS:-}" ]; then
|
if [ -z "${XLA_FLAGS:-}" ]; then
|
||||||
export XLA_FLAGS="--xla_gpu_unsafe_fallback_to_driver_on_ptxas_not_found"
|
export XLA_FLAGS="--xla_gpu_unsafe_fallback_to_driver_on_ptxas_not_found"
|
||||||
@@ -238,6 +253,32 @@ fi
|
|||||||
echo " Wrote training_parameters.yaml"
|
echo " Wrote training_parameters.yaml"
|
||||||
rm -rf "${WORK_DIR}/trained_models/wakeword"
|
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="$(
|
wake_word_filename="$(
|
||||||
echo "${WAKE_WORD}" \
|
echo "${WAKE_WORD}" \
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
| 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()
|
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
|
||||||
probability_cutoff = 0.97
|
probability_cutoff = 0.97
|
||||||
sliding_window_size = 5
|
sliding_window_size = 5
|
||||||
|
calibration = {}
|
||||||
|
|
||||||
if calibration_path.exists():
|
if calibration_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -469,21 +511,60 @@ if calibration_path.exists():
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"⚠️ Failed to read detector calibration ({exc}); using defaults.")
|
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 = {
|
meta = {
|
||||||
"type": "micro",
|
"type": "micro",
|
||||||
"wake_word": os.environ["WAKE_WORD_TITLE"],
|
"wake_word": os.environ["WAKE_WORD_TITLE"],
|
||||||
|
"label": os.environ["WAKE_WORD_TITLE"].replace("_", " ").title(),
|
||||||
"author": "Tater Totterson",
|
"author": "Tater Totterson",
|
||||||
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
|
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
|
||||||
"model": os.environ["TFLITE_FILENAME"],
|
"model": os.environ["TFLITE_FILENAME"],
|
||||||
"trained_languages": [language],
|
"trained_languages": [language],
|
||||||
"version": 2,
|
"version": 2,
|
||||||
|
"model_format": "tflite_stream_state_internal_quant",
|
||||||
|
"quantization": "int8",
|
||||||
|
"sample_rate": 16000,
|
||||||
"micro": {
|
"micro": {
|
||||||
"probability_cutoff": round(probability_cutoff, 2),
|
"probability_cutoff": probability_cutoff,
|
||||||
"sliding_window_size": sliding_window_size,
|
"sliding_window_size": sliding_window_size,
|
||||||
"feature_step_size": 10,
|
"feature_step_size": 10,
|
||||||
"tensor_arena_size": 30000,
|
"tensor_arena_size": 30000,
|
||||||
"minimum_esphome_version": "2024.7.0",
|
"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")
|
json_path.write_text(json.dumps(meta, indent=4) + "\n", encoding="utf-8")
|
||||||
PY
|
PY
|
||||||
|
|||||||
54
dockerfile.blackwell
Normal file
54
dockerfile.blackwell
Normal 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"]
|
||||||
@@ -262,6 +262,23 @@
|
|||||||
margin-bottom: 0;
|
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 {
|
.firmwareSteps {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -362,6 +379,17 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtimeWakeWordMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtimeWakeWordMeta .pill {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.runtimeWakeWordItem button {
|
.runtimeWakeWordItem button {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1283,7 +1311,7 @@
|
|||||||
<div class="logo"></div>
|
<div class="logo"></div>
|
||||||
<div>
|
<div>
|
||||||
<h1>microWakeWord Trainer Studio</h1>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1526,7 +1554,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="firmwareKicker">Firmware Studio</div>
|
<div class="firmwareKicker">Firmware Studio</div>
|
||||||
<h3>Prebuilt Tater Firmware Flasher</h3>
|
<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">
|
<div class="firmwareSteps" aria-label="Firmware flashing steps">
|
||||||
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
|
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
|
||||||
<span class="firmwareStepChip"><b>2</b> Select target</span>
|
<span class="firmwareStepChip"><b>2</b> Select target</span>
|
||||||
@@ -1537,14 +1565,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="card runtimeWakeWordCard">
|
||||||
<div class="runtimeWakeWordHeader">
|
<div class="runtimeWakeWordHeader">
|
||||||
<div class="runtimeWakeWordTitle">
|
<div class="runtimeWakeWordTitle">
|
||||||
<span class="runtimeWakeWordBadge">3.0.3+</span>
|
<span class="runtimeWakeWordBadge">v1</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="firmwareKicker">Live Model Switching</div>
|
<div class="firmwareKicker">Live Model Switching</div>
|
||||||
<h3>Tater firmware 3.0.3 or higher can swap wake words live</h3>
|
<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 your satellite's Home Assistant <code>microWakeWord Model URL</code> entity.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill ok">No reflash needed</span>
|
<span class="pill ok">No reflash needed</span>
|
||||||
@@ -1561,7 +1594,7 @@
|
|||||||
<span class="firmwareStepBadge">1</span>
|
<span class="firmwareStepBadge">1</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Firmware Image</h3>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1585,13 +1618,14 @@
|
|||||||
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
|
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<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>
|
||||||
<div class="firmwareTargetGrid">
|
<div class="firmwareTargetGrid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<strong>Detected Device</strong>
|
<strong>Detected Device</strong>
|
||||||
<select id="firmwareDeviceSelect">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -1623,7 +1657,7 @@
|
|||||||
<button id="cleanFirmwareBtn" type="button">Clear downloaded images</button>
|
<button id="cleanFirmwareBtn" type="button">Clear downloaded images</button>
|
||||||
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
|
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2031,7 +2065,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return String(text || "")
|
return String(text ?? "")
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">");
|
.replaceAll(">", ">");
|
||||||
@@ -2056,6 +2090,12 @@
|
|||||||
return { label: item.capture_label || "Captured", cls: "" };
|
return { label: item.capture_label || "Captured", cls: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDetectionProfile(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
function renderCapturedItems(payload) {
|
function renderCapturedItems(payload) {
|
||||||
const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 };
|
const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 };
|
||||||
uiState.captured = data;
|
uiState.captured = data;
|
||||||
@@ -2084,6 +2124,18 @@
|
|||||||
if (item.wake_word) meta.push(`<span class="pill">${escapeHtml(item.wake_word)}</span>`);
|
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.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.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 formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit";
|
||||||
const when = formatTimestamp(item.captured_at || item.received_at);
|
const when = formatTimestamp(item.captured_at || item.received_at);
|
||||||
const actionDisabled = uiState.reviewBusy ? "disabled" : "";
|
const actionDisabled = uiState.reviewBusy ? "disabled" : "";
|
||||||
@@ -2441,7 +2493,7 @@
|
|||||||
|
|
||||||
async function browserUsbSelectPort(selector = "") {
|
async function browserUsbSelectPort(selector = "") {
|
||||||
if (!window.isSecureContext || !navigator.serial) {
|
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 port = await navigator.serial.requestPort();
|
||||||
const key = String(selector || "default").trim() || "default";
|
const key = String(selector || "default").trim() || "default";
|
||||||
@@ -2449,6 +2501,11 @@
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function browserUsbStoredPort(selector = "") {
|
||||||
|
const key = String(selector || "default").trim() || "default";
|
||||||
|
return uiState.browserUsb.ports?.[key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeEsptoolJsModule(module) {
|
function normalizeEsptoolJsModule(module) {
|
||||||
if (module?.ESPLoader && module?.Transport) {
|
if (module?.ESPLoader && module?.Transport) {
|
||||||
return module;
|
return module;
|
||||||
@@ -2507,19 +2564,15 @@
|
|||||||
return chunks.join("");
|
return chunks.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function browserUsbSetSignals(port, signals, timeoutMs = 1200) {
|
async function browserUsbWithTimeout(promise, timeoutMs, message) {
|
||||||
if (!port || typeof port.setSignals !== "function") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let timeoutId = 0;
|
let timeoutId = 0;
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
return await Promise.race([
|
||||||
port.setSignals(signals),
|
promise,
|
||||||
new Promise((_resolve, reject) => {
|
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 {
|
} finally {
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(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) {
|
async function browserUsbHardResetAfterFlash(transport, loader, port) {
|
||||||
try {
|
try {
|
||||||
appendFirmwareLog("Resetting device after USB flash.");
|
appendFirmwareLog("Resetting device after USB flash.");
|
||||||
@@ -2648,26 +2713,39 @@
|
|||||||
: "No files selected";
|
: "No files selected";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FIRMWARE_USB_DEVICE_VALUE = "__browser_usb_flash__";
|
||||||
|
|
||||||
|
function firmwareUsbSelected() {
|
||||||
|
return $("firmwareDeviceSelect").value === FIRMWARE_USB_DEVICE_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
function renderFirmwareDevices(devices, message) {
|
function renderFirmwareDevices(devices, message) {
|
||||||
const list = Array.isArray(devices) ? devices : [];
|
const list = Array.isArray(devices) ? devices : [];
|
||||||
uiState.firmware.devices = list;
|
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 = [
|
$("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) => {
|
...list.map((device, index) => {
|
||||||
const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`;
|
const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`;
|
||||||
return `<option value="${index}">${escapeHtml(label)}</option>`;
|
return `<option value="${index}">${escapeHtml(label)}</option>`;
|
||||||
}),
|
}),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
setPill($("firmwareDetectStatus"), `${list.length} detected`, "ok");
|
if (!list.length) {
|
||||||
|
setPill($("firmwareDetectStatus"), message || "No devices detected", "warn");
|
||||||
if (!($("firmwareHost").value || "").trim()) {
|
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() && !firmwareUsbSelected()) {
|
||||||
$("firmwareDeviceSelect").value = "0";
|
$("firmwareDeviceSelect").value = "0";
|
||||||
applySelectedFirmwareDevice().catch((error) => {
|
applySelectedFirmwareDevice().catch((error) => {
|
||||||
setPill($("firmwareStatus"), "Device settings failed", "warn");
|
setPill($("firmwareStatus"), "Device settings failed", "warn");
|
||||||
@@ -2687,6 +2765,14 @@
|
|||||||
async function applySelectedFirmwareDevice() {
|
async function applySelectedFirmwareDevice() {
|
||||||
const indexText = $("firmwareDeviceSelect").value;
|
const indexText = $("firmwareDeviceSelect").value;
|
||||||
if (indexText === "") return;
|
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)];
|
const device = uiState.firmware.devices[Number(indexText)];
|
||||||
if (!device) return;
|
if (!device) return;
|
||||||
await flushFirmwareProfileSave();
|
await flushFirmwareProfileSave();
|
||||||
@@ -2711,6 +2797,7 @@
|
|||||||
|
|
||||||
function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) {
|
function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) {
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
|
if (firmwareUsbSelected()) return;
|
||||||
if (template.target_host) {
|
if (template.target_host) {
|
||||||
$("firmwareHost").value = template.target_host;
|
$("firmwareHost").value = template.target_host;
|
||||||
}
|
}
|
||||||
@@ -2749,6 +2836,13 @@
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
|
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
|
||||||
url: String(item.json_url || "").trim(),
|
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);
|
.filter((item) => item.url);
|
||||||
|
|
||||||
@@ -2757,15 +2851,27 @@
|
|||||||
return;
|
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 class="runtimeWakeWordItem">
|
||||||
<div>
|
<div>
|
||||||
<strong>${escapeHtml(item.label)}</strong>
|
<strong>${escapeHtml(item.label)}</strong>
|
||||||
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
|
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
|
||||||
|
${meta.length ? `<div class="runtimeWakeWordMeta">${meta.join("")}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
|
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
|
||||||
</div>
|
</div>
|
||||||
`).join("");
|
`;
|
||||||
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyTextToClipboard(text) {
|
async function copyTextToClipboard(text) {
|
||||||
@@ -3023,6 +3129,9 @@
|
|||||||
|
|
||||||
function firmwareTemplateQuery() {
|
function firmwareTemplateQuery() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
if (firmwareUsbSelected()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const host = ($("firmwareHost").value || "").trim();
|
const host = ($("firmwareHost").value || "").trim();
|
||||||
const port = ($("firmwarePort").value || "3232").trim();
|
const port = ($("firmwarePort").value || "3232").trim();
|
||||||
if (host) params.set("target_host", host);
|
if (host) params.set("target_host", host);
|
||||||
@@ -3143,7 +3252,7 @@
|
|||||||
syncRenderedWakeSoundSelection({ fromPicker: true });
|
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;
|
if (!ok) return;
|
||||||
|
|
||||||
uiState.firmwareBusy = true;
|
uiState.firmwareBusy = true;
|
||||||
@@ -3197,15 +3306,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.isSecureContext || !navigator.serial) {
|
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;
|
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;
|
if (!ok) return;
|
||||||
|
|
||||||
let port;
|
let port;
|
||||||
try {
|
try {
|
||||||
port = await browserUsbSelectPort(template.value);
|
port = browserUsbStoredPort(template.value) || await browserUsbSelectPort(template.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("USB device selection failed: " + error.message);
|
alert("USB device selection failed: " + error.message);
|
||||||
return;
|
return;
|
||||||
@@ -3234,6 +3343,7 @@
|
|||||||
appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash...");
|
appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash...");
|
||||||
await flashBrowserUsbPort(port, artifact);
|
await flashBrowserUsbPort(port, artifact);
|
||||||
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
|
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");
|
setPill($("firmwareStatus"), "USB flash finished", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed.");
|
appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed.");
|
||||||
@@ -3320,6 +3430,7 @@
|
|||||||
const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
|
const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
|
||||||
const firmwareSelection = selectedFirmwareTemplate();
|
const firmwareSelection = selectedFirmwareTemplate();
|
||||||
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available);
|
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available);
|
||||||
|
const usbSelected = firmwareUsbSelected();
|
||||||
|
|
||||||
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
|
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
|
||||||
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
|
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
|
||||||
@@ -3331,10 +3442,10 @@
|
|||||||
$("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0;
|
$("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0;
|
||||||
$("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy;
|
$("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy;
|
||||||
$("refreshFirmwareBtn").disabled = uiState.firmwareBusy;
|
$("refreshFirmwareBtn").disabled = uiState.firmwareBusy;
|
||||||
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate;
|
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate;
|
||||||
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
|
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
|
||||||
$("openFirmwareConsoleBtn").disabled = false;
|
$("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;
|
$("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import uuid
|
|||||||
import wave
|
import wave
|
||||||
from array import array
|
from array import array
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from math import log10
|
from math import isfinite, log10
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Callable, Optional, Tuple
|
from typing import Dict, Any, List, Callable, Optional, Tuple
|
||||||
from urllib.parse import quote, urlparse
|
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 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_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_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("ESPHOME_DISCOVERY_SECONDS", "2.5"))
|
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_MAX_LOG_LINES = int(os.environ.get("FIRMWARE_MAX_LOG_LINES", "500"))
|
||||||
FIRMWARE_GITHUB_OWNER = os.environ.get("FIRMWARE_GITHUB_OWNER", "TaterTotterson")
|
FIRMWARE_GITHUB_OWNER = os.environ.get("FIRMWARE_GITHUB_OWNER", "TaterTotterson")
|
||||||
FIRMWARE_GITHUB_REPO = os.environ.get("FIRMWARE_GITHUB_REPO", "microWakeWords")
|
FIRMWARE_GITHUB_REPO = os.environ.get("FIRMWARE_GITHUB_REPO", "microWakeWords")
|
||||||
FIRMWARE_GITHUB_REF = os.environ.get("FIRMWARE_GITHUB_REF", "main")
|
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"))
|
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_PREBUILT_DIR = FIRMWARE_CACHE_DIR / "prebuilt_firmware"
|
||||||
FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS = float(os.environ.get("FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS", "120"))
|
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 = (
|
FIRMWARE_TEMPLATE_SPECS = (
|
||||||
{
|
{
|
||||||
"key": "voicepe",
|
"key": "voicepe",
|
||||||
"label": "VoicePE",
|
"label": "Voice PE",
|
||||||
"description": "VoicePE satellite prebuilt firmware",
|
"description": "Tater Native firmware for Voice PE satellites",
|
||||||
|
"flash_size": "16MB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "satellite1",
|
"key": "satellite1",
|
||||||
"label": "Sat1",
|
"label": "Satellite1",
|
||||||
"description": "Satellite1 prebuilt firmware",
|
"description": "Tater Native firmware for Satellite1 devices",
|
||||||
},
|
"flash_size": "16MB",
|
||||||
{
|
|
||||||
"key": "respeaker_lite",
|
|
||||||
"label": "ReSpeaker Lite",
|
|
||||||
"description": "ReSpeaker Lite prebuilt firmware",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "koala",
|
|
||||||
"label": "Koala Satellite",
|
|
||||||
"description": "Koala satellite prebuilt firmware",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "respeaker_xvf3800",
|
"key": "respeaker_xvf3800",
|
||||||
"label": "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 = (
|
FIRMWARE_PREBUILT_LATEST_URL = os.environ.get(
|
||||||
f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/prebuilt_firmware/latest.json"
|
"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}
|
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()
|
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()
|
_sync_trained_wake_word_artifacts()
|
||||||
base = str(base_url or "").rstrip("/")
|
base = str(base_url or "").rstrip("/")
|
||||||
rows: List[Dict[str, str]] = []
|
rows: List[Dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
for json_path in sorted(TRAINED_WAKE_WORDS_DIR.glob("*.json")):
|
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)
|
seen.add(safe)
|
||||||
|
|
||||||
wake_word = str(meta.get("wake_word") or safe.replace("_", " ")).strip()
|
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)}"
|
json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
|
||||||
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
|
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
|
||||||
if base:
|
if base:
|
||||||
@@ -349,6 +383,17 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
|
|||||||
"model_url": model_url,
|
"model_url": model_url,
|
||||||
"json_file": json_path.name,
|
"json_file": json_path.name,
|
||||||
"model_file": model_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
|
return rows
|
||||||
@@ -684,6 +729,30 @@ def _parse_float(value: Any) -> float | None:
|
|||||||
return 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:
|
def _audio_sidecar_path(audio_path: Path) -> Path:
|
||||||
return audio_path.with_suffix(".json")
|
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")),
|
"blocked_by_vad": bool(meta.get("blocked_by_vad")),
|
||||||
"max_probability": meta.get("max_probability"),
|
"max_probability": meta.get("max_probability"),
|
||||||
"average_probability": meta.get("average_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 {},
|
"detected_format": meta.get("detected_format") or {},
|
||||||
"final_format": final_format,
|
"final_format": final_format,
|
||||||
"postprocess": meta.get("postprocess") or {},
|
"postprocess": meta.get("postprocess") or {},
|
||||||
@@ -1453,6 +1531,15 @@ def _firmware_template_spec(template_key: str) -> Dict[str, Any]:
|
|||||||
raise ValueError("Unknown firmware template.")
|
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:
|
def _firmware_raw_url(path: str) -> str:
|
||||||
clean = str(path or "").strip().lstrip("/")
|
clean = str(path or "").strip().lstrip("/")
|
||||||
return f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/{clean}"
|
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
|
return token
|
||||||
clean = token.lstrip("/")
|
clean = token.lstrip("/")
|
||||||
quoted = "/".join(quote(part) for part in clean.split("/") if part)
|
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]:
|
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),
|
"source_binary": str(binary_path),
|
||||||
"binary_size": int(target_binary_path.stat().st_size),
|
"binary_size": int(target_binary_path.stat().st_size),
|
||||||
"erase_all": True,
|
"erase_all": True,
|
||||||
"flash_size": "4MB",
|
"flash_size": _firmware_template_flash_size(template_key),
|
||||||
"flash_mode": "dio",
|
"flash_mode": "dio",
|
||||||
"flash_freq": "40m",
|
"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]] = []
|
clean_devices: List[Dict[str, Any]] = []
|
||||||
for item in devices:
|
for item in devices:
|
||||||
host = str(item.get("host") or "").strip()
|
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:
|
if not host:
|
||||||
continue
|
continue
|
||||||
key = (host.lower(), int(item.get("port") or FIRMWARE_DEFAULT_OTA_PORT))
|
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]:
|
def _discover_esphome_devices() -> tuple[List[Dict[str, Any]], str]:
|
||||||
devices = _discover_with_zeroconf(FIRMWARE_DISCOVERY_SECONDS)
|
devices = _discover_with_zeroconf(FIRMWARE_DISCOVERY_SECONDS)
|
||||||
if devices:
|
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)
|
devices = _discover_with_dns_sd(FIRMWARE_DISCOVERY_SECONDS)
|
||||||
if devices:
|
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 --------------------
|
# -------------------- Routes --------------------
|
||||||
@@ -2967,6 +3057,15 @@ async def upload_captured_audio(
|
|||||||
"average_probability": _parse_float(
|
"average_probability": _parse_float(
|
||||||
extra_meta.get("average_probability") if average_probability is None else average_probability
|
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 "",
|
"notes": notes or extra_meta.get("notes") or "",
|
||||||
"converted": result["converted"],
|
"converted": result["converted"],
|
||||||
"detected_format": result["detected_format"],
|
"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_blocked_by_vad: str | None = Header(default=None),
|
||||||
x_max_probability: str | None = Header(default=None),
|
x_max_probability: str | None = Header(default=None),
|
||||||
x_average_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),
|
x_notes: str | None = Header(default=None),
|
||||||
):
|
):
|
||||||
raw_data = await request.body()
|
raw_data = await request.body()
|
||||||
@@ -3031,6 +3139,15 @@ async def upload_captured_audio_raw(
|
|||||||
"blocked_by_vad": _parse_bool(x_blocked_by_vad),
|
"blocked_by_vad": _parse_bool(x_blocked_by_vad),
|
||||||
"max_probability": _parse_float(x_max_probability),
|
"max_probability": _parse_float(x_max_probability),
|
||||||
"average_probability": _parse_float(x_average_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 "",
|
"notes": x_notes or "",
|
||||||
"converted": result["converted"],
|
"converted": result["converted"],
|
||||||
"detected_format": result["detected_format"],
|
"detected_format": result["detected_format"],
|
||||||
|
|||||||
Reference in New Issue
Block a user