Node Configuration¶
File: configs/node.yaml
Command: kenzy-node [config_path]
The node service runs on each room device. It captures microphone audio, detects the wake word, streams PCM to the server, and plays back TTS responses.
node.yaml is bootstrap-only: it holds just what the node needs to start up, log, and reach the server (log_level/verbose, server_url/discovery, and a stable node_id). On every boot the node pulls its full operational config from the server — audio device, sample rates, wakeword models/threshold/VAD, sounds, VAD timing, and its room name — and does not initialize audio until that config arrives. Configure all of it from the dashboard, keyed by the node's node_id. The operational keys below may still be set locally as a pre-connect fallback for any key the server does not push, but the dashboard/server is authoritative.
Full reference¶
| Key | Default | Description |
|---|---|---|
server_url |
null |
WebSocket URL of the kenzy-server. Leave null/empty to auto-discover the server on the LAN via mDNS; set an explicit ws:// (or wss:// for a TLS-enabled server) URL to skip discovery (e.g. across VLANs that block multicast). |
discovery.enabled |
true |
Browse for the server over mDNS when server_url is unset |
tls_verify |
false |
When connecting over wss:// (a TLS-enabled server): false = encrypted but the certificate is not verified — the right posture for a self-signed home-LAN cert, no CA install needed. true = require a certificate trusted by the system store. |
tls_ca |
— | Path to the server's certificate (or a CA bundle) to pin: connections verify against this file specifically. Implies verification. |
node_id |
(generated) | Stable primary identifier for this node. Leave unset — one is generated and written back to node.yaml on first run (or assigned at install with kenzy-init --node-id ID), then kept across restarts. The server keys the registry, per-node config, and all controls on it, so a node's identity (and its config) survives even when the room name changes or the device is reimaged. |
room_id |
null |
Human room name (e.g. kitchen). Server-owned: set it from the dashboard. Until the server provides one it falls back to the hostname. Sent to the assistant as context (used in conversation history). |
audio_device |
null |
PortAudio device name substring or integer index. null uses the system default. Use kenzy-devices to find the correct value. |
capture_sample_rate |
16000 |
Sample rate for microphone capture. Set to the device's native rate if it does not support 16000 Hz; audio is resampled automatically. |
playback_sample_rate |
24000 |
Sample rate for speaker output. Set to the device's native rate if it does not support 24000 Hz; TTS audio is resampled automatically. |
volume |
100 |
Playback volume [0–100]. Server-owned, applies live (config-pull). Settable from the dashboard or by voice ("turn it up", "set the volume to 40"). Affects TTS, intercom, and announcements. |
muted |
false |
Runtime only — not persisted. Mutes all playback except the wake-word ready chime, which stays audible (at a floor level) so you can tell the device is listening and knowingly unmute. Toggle from the dashboard or by voice ("mute"/"unmute"). A node always comes back un-muted after a restart. |
log_level |
"info" |
What the node prints to its console (debug/info/warning/error). Live-tunable from the dashboard. |
log_capture_level |
"debug" |
Server-owned. How deep the dashboard log viewer can see for this node (trace/debug/…), independent of log_level. Captured only while the dashboard's logs flag is on (otherwise zero overhead). Set trace to include per-frame audio logs. |
verbose |
false |
Also enables debug output from websockets and asyncio internals |
Let the dashboard tune these for you
wakeword_threshold, wakeword_vad_threshold, and silence_rms_threshold are
mic- and room-specific. Rather than guessing, use the Calibration panel in the
dashboard (or kenzy-node --calibrate on a headless node) to measure your room and
apply suggested values. See Calibrating a node's audio.
Wake word¶
| Key | Default | Description |
|---|---|---|
wakeword_models |
[] |
List of paths to .tflite or .onnx model files. Empty uses the bundled hey_ken_zee.tflite |
wakeword_threshold |
0.5 |
Confidence threshold [0.0–1.0] above which a detection fires |
wakeword_vad_threshold |
0.0 |
openwakeword Silero VAD gate [0.0–1.0]. Wake-word predictions are discarded unless the voice-activity score exceeds this. 0 disables it. Set to ~0.5 to suppress false detections on near-silence/noise. With it enabled you can safely lower wakeword_threshold (e.g. 0.4) for better real-speech sensitivity without reintroducing silence false-positives. The Silero VAD model is downloaded automatically by kenzy-setup. |
Voice activity detection (VAD)¶
| Key | Default | Description |
|---|---|---|
vad_enabled |
true |
When false, the node streams until the server sends STOP. Hard cap does not apply. |
silence_rms_threshold |
50 |
RMS amplitude [0–32767] below which a frame is considered silent |
silence_ms |
400 |
Consecutive silence (ms) that ends an active session, once speech_min_ms has been heard |
speech_min_ms |
400 |
Minimum speech (ms) that must be detected before silence detection activates. Prevents the session ending on the pause after the wake word. |
no_speech_timeout_ms |
15000 |
Timeout (ms) if no speech is heard after activation. Prevents indefinite streaming when the wake word fires accidentally. |
hard_cap_ms |
30000 |
Unconditional session ceiling (ms). The session ends regardless of VAD state. |
Sound files¶
| Key | Default | Description |
|---|---|---|
sound_ready |
null |
WAV file played on activation (the "chime"). null uses the bundled ready.wav. Accepts an absolute path or a bare filename loaded from the bundled sounds directory. |
sound_waiting |
null |
WAV file played while waiting for the server response. Plays once — it does not repeat, so a short chime here stays a single chime rather than one every couple of seconds (the bundled waiting.wav is a ~26 s ambient bed that covers a long request on its own). While it is still playing the spoken processing cues below duck it underneath and it continues after them; once it has finished they are simply spoken. It stops the instant the reply begins. null (or an empty string) disables it — pure silence while waiting. Provide a filename or path to enable it. |
sound_connect |
"connect.wav" |
Chime played when an intercom call connects (bundled default; path, or empty/null to disable). |
sound_disconnect |
"disconnect.wav" |
Chime played when an intercom call ends (bundled default; path, or empty/null to disable). |
sound_ringback |
"ringback.wav" |
The ring the caller hears while an intercom target room is being rung and asked to accept — loops until the call connects, is declined, or times out. Empty/null = silent wait. |
sound_timer |
"timer.wav" |
Tone played before a timer announcement. Unlike the other sounds, this is prepended by the server at fire time — a path is read on the server host, and changes apply live (no node restart). Empty/null = voice only. |
sound_alarm |
"alarm.wav" |
Tone played before each alarm ring — same server-side, live-apply behavior as sound_timer. The tone still plays even if TTS synthesis fails. Empty/null = voice only. |
sound_error |
"error.wav" |
Spoken cue when a request fails mid-pipeline ("I'm sorry, but I'm having trouble…"). Pre-recorded — not synthesized — so it still plays when TTS itself is the broken part. Same server-side, live-apply semantics. Empty/null = stay silent on failure. |
sound_thinking |
"thinking.wav" |
Spoken status note ("Working on it.") when a reply is taking a while — spoken at ~5 seconds if the answer hasn't started, ducked over the waiting sound (rather than cutting it) whenever that is still playing. Accepts a list of sounds: one plays at random, never the same twice in a row. Same server-side, live-apply semantics. Empty/null = never speak it. |
sound_working |
"working.wav" |
Second acknowledgement ("Still working on it.") for the long tail, ~8 seconds after the first cue finishes (a constant felt gap, however long the first clip is) — after it, Kenzy stays deliberately quiet until the answer (or the failure apology). Same pool/duck/live semantics as sound_thinking. Empty/null = skip it. |
dialog_no_speech_timeout_ms |
8000 |
How long Kenzy waits for your answer during a multi-turn dialog before giving up (the soft end cue = "I stopped waiting"). Live-applied. |
dialog_onset_ms |
300 |
Speech required to start a dialog turn — either sustained speech of this length, or a shorter complete word ("yes", "Boo") that ends in silence. A cough or clink can't trip the mic; your first word is buffered and kept whole. Live-applied. |
dialog_onset_vad_threshold |
0.5 |
Voice-activity confidence gate for dialog onset (falls back to energy sensing if the VAD model is absent). Live-applied. |
wake_onset_ms |
400 |
One-breath commands. After the wake word fires, the ready chime is held this long. Keep talking — "Hey Kenzy turn on the lights", no pause — and the chime is skipped entirely (it would land on top of your sentence); the command is captured from its first word and answered as usual, with the processing sound as the acknowledgement. Pause instead, and the chime plays and Kenzy listens exactly as before — just this many milliseconds later. 0 = the pre-gate behavior: chime the instant the wake word fires. Live-applied. |
hardware_aec |
true |
Whether this room's speaker does acoustic echo cancellation. Calibration detects and sets this automatically (it plays a known sound through the node's speaker and measures the echo); set it manually only to override an ambiguous reading. Set false for a non-AEC speaker and the room runs half-duplex: see the table below for exactly what changes. Live-applied; shown as a "no AEC" badge on the room's fleet card. |
sound_offline |
null |
Cue played when the wake word fires while the node has no server connection. null = stay silent. The activation chime is deliberately never used here: it means "I'm listening", and a room that cannot reach the server isn't. Needs a restart. |
Speakerphone volume buttons¶
Many USB speakerphones have physical +/− buttons that work on a desktop only
because a media-key daemon consumes them; a headless node has no such daemon, so
they emit valid input events into the void. Opt in per node and the buttons move
Kenzy's canonical volume — the same clamped, persisted value the dashboard,
voice commands and MQTT see, pushed back live by the server. Nothing touches an
ALSA mixer, so there is never a second volume truth.
Set it up where you pick the room's audio device. Run Set up / calibrate
audio on the node's dashboard page: when the device you choose also carries
volume buttons, the device step offers them right there — one checkbox, applied
with the device. The three keys below then appear in the node's config grid
under Speakerphone volume buttons for later changes, and in node_defaults
for fleet-wide settings.
| Key | Default | What it does |
|---|---|---|
volume_buttons |
false |
The opt-in. Off costs nothing. Linux-only; anywhere else it is a clean no-op with a status line. Live-applied. |
volume_button_device |
auto |
auto considers only input endpoints that are USB siblings of this node's own audio device and advertise standard volume keys — a keyboard with media keys, or any other device, can never qualify. When audio_device is an alias like default (so no USB parent resolves), auto falls back to the only volume-keyed endpoint whose USB parent also hosts a sound card — the one speakerphone-shaped thing plugged in. More than one candidate does nothing and reports them; set a stable device-name match (e.g. "SP300U") to choose. Never an /dev/input/eventN path — those change across boots. Live-applied. |
volume_button_step |
5 |
Volume points per press, 1–20. Live-applied. On devices that report holds, a held button repeats (a beat of grace, then steady steps, rate-limited). Some speakerphones don't report holds at all — the SP300U emits an identical instant press/release burst whether tapped or held, so hold-to-repeat cannot exist there; tap per step, and raise the step if that feels slow. |
The node page shows the endpoint's status (found / not found / why). Unplugging
the speakerphone never disturbs capture or playback — the watcher just reports
absent and rescans. Requires the mediakeys extra (pip install
"kenzy[node,mediakeys]"), which the installer and kenzy-deploy both add to
node hosts by default — the recommended node is a USB speakerphone with echo
cancellation, and those carry volume keys. Without the extra the status line
says so and nothing else changes.
It is still a separate extra rather than part of node, because evdev ships
source-only on PyPI (no wheels, any platform) and so needs python3-dev and
gcc on the target. Both installers therefore install it as its own step and
tolerate a build failure: you lose the buttons, never the install — and
never an upgrade sweep on a host whose toolchain went missing. Opt out with
install.sh --no-media-keys or media_keys: false in deploy.yaml.
Upgrades keep it: the node's one-click upgrade carries the extra when evdev is
already present (and never adds it otherwise, so a node without build tools
can't break on it), and kenzy-deploy re-attempts it for any host whose
extras: list includes mediakeys. Adding it to a node that predates 5.0.4 is
a few one-time steps —
Upgrading → 5.0.4.
Reading /dev/input/* also needs the node's user in the input group:
sudo usermod -aG input <user>, then reboot. Without it the watcher sees no
devices at all and the status line says so, naming the group.
Restarting the node service is not enough, and this is the part that wastes an
afternoon. Supplementary groups are fixed per process when a login session is
created, so the systemd --user manager keeps its pre-usermod credentials and
every service it forks inherits them — and with lingering enabled (the installer's
default) that manager survives logout too. Reboot, or
sudo loginctl terminate-user <user> and log in again. SupplementaryGroups=input
in the unit is not an option for per-user installs: setting groups needs
privilege the user manager doesn't have, so the unit fails with status 216. It
works only in the system units kenzy-deploy writes for root installs.
Physical mute buttons are deliberately not handled: on the tested devices
they mute the microphone in hardware with no host involvement, and Kenzy's own
muted is untouched in both directions — a volume press while muted adjusts the
stored volume and nothing else.
Staying reachable (watchdog)¶
A node that loses its server keeps running: the microphone works, the wake word still fires, the chime still plays. From inside the room nothing looks wrong, which is exactly why an orphaned node can go unnoticed for days. Three things guard against that — the first two need no configuration:
- The node remembers the address it last registered with and tries that before browsing for the server over mDNS. A room that has been talking to a server for days is never orphaned by a single unanswered multicast query.
- Discovery is bounded. A hung mDNS browse used to park the reconnect loop outright, with no retry and nothing in the log.
- The watchdog below notices, complains, and as a last resort restarts.
| Key | Default | Description |
|---|---|---|
watchdog.enabled |
true |
Run the reconnect watchdog. Needs a restart. |
watchdog.warn_minutes |
5 |
Log loudly (and keep logging) once the node has gone this long without registering. Live-applied. |
watchdog.wedge_minutes |
5 |
Restart the node process if its reconnect loop hasn't run for this long. A loop that has stopped turning is stuck in a call that will never return — no amount of waiting recovers it, only a new process. Live-applied. |
watchdog.reexec_minutes |
30 |
Restart the node process after this long unable to register, even if the loop is still turning. Deliberately much longer than wedge_minutes so an ordinary server outage doesn't make every room restart in a loop. 0 = never. Live-applied. |
This one is fleet-wide only — set it under node_defaults in server.yaml
(Settings → the server's own config), not per room. Per-node overrides merge
shallowly, so a room that set just one of these would silently drop the other
three:
watchdog:
enabled: true
warn_minutes: 5
wedge_minutes: 5
reexec_minutes: 30 # 0 = complain, but never restart myself
A node whose clock is wrong cannot join
A node proves its identity with a signed, timestamp-fresh hello, so a
clock more than two minutes off the server's is refused — every time, until
the clock is fixed. Boards without a battery-backed RTC (most SBCs) boot with
whatever their hardware clock drifted to, so the units ship ordered after
time-sync.target. Under chrony that only means something if
chrony-wait.service is enabled on the node host. Pointing the fleet's NTP at
the Kenzy server host is the sturdiest arrangement: it is the clock the check
compares against, and it removes any dependence on internet time.
Rooms without echo cancellation (hardware_aec: false)¶
Kenzy assumes an echo-cancelling speakerphone (see the hardware guide) — it's what lets her hear you while she's making sound. On a speaker without AEC, her own audio floods the microphone, so features that depend on listening-during-playback are turned off cleanly rather than left to misbehave:
| Feature | Without AEC |
|---|---|
| Voice interrupt while Kenzy is speaking/playing | Off — the wake word works again the instant playback ends |
| Talk-over (barge-in during a dialog) | Off — she can't hear you over herself; you answer after she finishes (3.6.0 strict turns). With AEC, you can talk over her and she yields |
| Intercom (live two-way calls) | Unavailable — Kenzy politely refuses (a two-way call without echo cancellation is a feedback loop) |
| Alarms | Refused at set time with an offer of a timer or reminder instead (an alarm's ring loop can only be silenced by voice — impossible over the ringing). An already-set alarm still fires, once, timer-style |
| Timers, reminders, announcements, all voice commands, multi-turn dialogs | Work normally |
Zero-config nodes (discovery + config-pull)
A node needs no operational local config. With server_url unset it finds the server via mDNS, generates a stable node_id on first run (or one assigned at install via kenzy-init --node-id), and blocks until the server answers — it connects, sends hello, and waits for the server's config frame before initializing audio. That effective config is the server's node_defaults plus any per-node override in configs/nodes/<node_id>.yaml. Hardware keys (audio_device, sample rates, wakeword_models/VAD gate, sounds) are applied as the audio stack is built on this first pull; a later change to a hardware key needs a restart (one click in the dashboard). Live-tunable keys (wake-word threshold, silence RMS, VAD timing) apply immediately on every push. So a room device can run with an essentially empty node.yaml, and everything — including its room name — is configured from the dashboard and centralised on the server. Pre-seed a node by creating configs/nodes/<node_id>.yaml on the server before the device first connects. See Server Configuration.
Env-only bootstrap (no node.yaml)
A node can start from the environment alone — no config file at all — which is the tidiest way to provision a room device:
| Variable | Purpose |
|---|---|
KENZY_SERVER_URL |
How to reach the server (ws:///wss://; http(s):// and a bare host:port are accepted and normalized). Omit to auto-discover via mDNS. |
KENZY_SERVER_TOKEN |
The shared join token (if the server requires one). KENZY_SERVICE_TOKEN is still accepted as a legacy alias. |
KENZY_NODE_ID |
A stable id for this node. When set it is authoritative — used as-is and never written to disk — so two node instances can run on one machine (two speakerphones, two units, two ids). Omit and the node generates + persists one on first run. |
These override any node.yaml value, so a systemd unit with a few
Environment= lines is a complete node install; everything operational is
still pulled from the server. (KENZY_NODE_ID unset falls back to the
generate-and-persist behavior described above.)
Finding the right device name
Run kenzy-devices after install. It tests every PortAudio device against Kenzy's required sample rates and prints ready-to-paste node.yaml settings including capture_sample_rate and playback_sample_rate if resampling is needed.
Prefer a speakerphone with hardware AEC
Use a USB speakerphone with built-in acoustic echo cancellation as the node's mic+speaker. Kenzy does not cancel echo itself, so without hardware AEC the node hears its own TTS playback and may falsely wake or interrupt — otherwise you must handle echo cancellation outside Kenzy.
Custom wake word
Custom wake word models can be trained at openWakeWord and pointed to via wakeword_models. Both .tflite and .onnx formats are supported.
Example¶
A typical node only needs the bootstrap keys — audio and tuning come from the server:
log_level: "info"
server_url: null # null = discover the server via mDNS
discovery:
enabled: true
# node_id is generated and written here automatically on first run — leave it unset
The operational keys may still be set locally as a pre-connect fallback for any key the server does not push (e.g. to pin a device before the node is configured in the dashboard):
audio_device: "Anker PowerConf S330" # substring of name shown by kenzy-devices
capture_sample_rate: 48000 # device native rate; resampled to 16000 Hz
playback_sample_rate: 48000 # device native rate; resampled to 24000 Hz
wakeword_threshold: 0.4 # lower is safe once VAD gating is on
wakeword_vad_threshold: 0.5 # reject wake-word hits on near-silence/noise