Listens on an ntfy topic, blinks a USB lamp 💡 (via uhubctl), and reads messages
aloud in Polish 🗣️ (Piper TTS) through the audio jack 🔊.
The ntfy message body is the literal text to read aloud. Parameters (voice,
repeat count, replay) ride in the Tags header as key=value entries, not in
the body -- ntfy does not forward arbitrary custom HTTP headers to
subscribers, but Tags is one of the documented fields that does survive:
curl -H "Tags: voice=justyna,repeat=2" -d "Testy padły" https://ntfy.sh/<your-topic>Recognized keys:
| Key | Meaning |
|---|---|
voice=<name> |
Piper voice name (see table below) |
repeat=<n> |
speak the body n times, separated by " Powtarzam! " ("Repeating!"), capped at 10 (MAX_REPEAT_COUNT) |
replay=<index> |
replay message <index> from the last 10 messages instead of speaking the body (-1 = most recent, -2 = second most recent); the body is ignored when this is set |
speed=<x> |
tempo, 1.0 = normal, 1.5 = half again as fast, 0.7 = slower (clamped to 0.5–2.0) |
variation=<x> |
how far the voice strays from its average -- pitch/timbre wobble. Piper's default is ~0.667; lower is flatter and more monotone, higher is livelier but can wobble (clamped to 0.0–1.5) |
rhythm=<x> |
how far per-syllable timing strays from the predicted durations. Piper's default is ~0.8 (clamped to 0.0–1.5) |
Tags without = (ntfy also uses tags for plain emoji/text markers) and
unrecognized keys are ignored. A body with no Tags header at all is spoken
as plain text with the default voice, spoken once.
speed, variation and rhythm are this protocol's names for Piper's
--length_scale, --noise_scale and --noise_w. speed is the inverse
of length_scale (a caller thinks in "1.5x faster", not "0.67x the phoneme
duration"); the other two pass through unchanged. An out-of-range value is
clamped rather than rejected, and a non-numeric one is ignored -- none of
them is worth failing an alert over. Left unset, no flag is passed at all
and Piper uses its own defaults, so an untagged message sounds exactly as it
did before these existed. Instance-wide defaults live in
KRZYKACZ_PIPER_SPEED / _VARIATION / _RHYTHM; a tag overrides them per
message. The live ranges are in GET /v1/limits.
The espeak-ng voices (below) honour the same three knobs, mapped onto what espeak-ng has. Each is anchored so that Piper's default lands on espeak-ng's default, which leaves an untagged message neutral:
| Tag | espeak-ng flag | Mapping |
|---|---|---|
speed |
-s words per minute |
175 * speed (0.5-2.0 is 87-350 wpm) |
variation |
-p pitch, 0-99 |
50 * variation / 0.667, clamped -- pitch is the closest audible stand-in for "how far from the voice's middle" |
rhythm |
-g pause between words, in 10 ms units |
(rhythm - 0.8) * 10, floored at 0 |
These are approximations: variation and rhythm are VITS sampling
parameters and espeak-ng has no exact equivalent. In particular a rhythm
below 0.8 is the same as 0.8, because a word gap can't be negative. The
instance-wide KRZYKACZ_PIPER_SPEED / _VARIATION / _RHYTHM defaults apply
to the espeak-ng voices as well.
voice (allowed characters: letters, digits, -, _, max 64 chars) selects a
Piper voice by name from KRZYKACZ_PIPER_VOICES, or one of the espeak-ng voices
from KRZYKACZ_ESPEAK_VOICES. A missing tag or unknown name falls back to the
default voice (KRZYKACZ_PIPER_DEFAULT_VOICE).
Supported voice values in the default configuration (see "Piper voices" below):
voice |
Description |
|---|---|
| (field absent) | default voice -- darkman |
darkman |
default voice, male |
justyna |
female voice |
jarvis |
male voice |
meski |
male voice |
zenski |
female voice |
gosia |
female voice |
bass |
male voice, high quality |
mc_speech |
male voice |
espeak_male, espeak_male2, espeak_male3 |
espeak-ng male voices |
espeak_female, espeak_female2, espeak_female3 |
espeak-ng female voices |
espeak_whisper |
espeak-ng, whispered |
espeak_croak |
espeak-ng, croaky |
espeak_announcer |
espeak-ng, public-address style |
espeak_robot |
espeak-ng, robotic |
A value outside this list isn't an error -- it silently falls back to the default
voice (with a warning in the log). The espeak backend (KRZYKACZ_TTS=espeak)
doesn't know the Piper names -- there, voice is either one of the espeak_*
names above or passed straight through as an espeak-ng language/voice code
(e.g. pl, en).
A sound effect can be woven anywhere into the body by wrapping its filename
in <angle brackets> -- not just at the start -- and everything plays back
in the order it appears: sounds and speech, interleaved. A file of that name
is played from KRZYKACZ_ASSETS_DIR (any format ffmpeg can decode -- mp3,
ogg, wav, ...) at that point, e.g.:
curl -d "<boom.mp3> Tests failed" https://ntfy.sh/<your-topic>
curl -d "Uwaga <siren.mp3> ewakuacja" https://ntfy.sh/<your-topic>
curl -d "<game_over> Tests failed <fight> Fixing now" https://ntfy.sh/<your-topic>Suffix a tag with *N to play that one sound N times back to back
(capped at 10, MAX_EFFECT_REPEAT) without writing the tag out N times --
useful for e.g. someone knocking:
curl -d "<knock*3> Ktoś puka" https://ntfy.sh/<your-topic>Adjacent sounds (several tags in a row, or one *N tag) are decoded
separately but played back as a single gapless clip, rather than as N
separate device opens. A message may carry at most 16 effect hits total
(MAX_EFFECTS, *N counted); past that the surplus is dropped rather than
the message failing.
The filename can't contain / or .. (protects against escaping the assets
directory). A missing effect file doesn't block the rest of the message --
that one sound is simply skipped.
A message longer than 800 bytes (MAX_CONTENT_BYTES, roughly a minute of
speech) is truncated -- this is meant to be read aloud on the spot, not
archived. The fully-joined text after repeat duplication is separately
capped at 1600 bytes (MAX_SPOKEN_BYTES), so a high repeat on a long message
can't run for minutes either. repeat replays the whole sequence, sounds
included, not just the spoken part.
curl -H "Tags: voice=justyna,repeat=2" \
-d "<game_over> Tests failed" \
https://ntfy.sh/<your-topic>In the justyna voice, this plays: game_over, "Tests failed.", "Powtarzam!",
game_over again, "Tests failed." again -- repeat=2 replays the effect too,
not just the sentence.
scripts/krzykacz.sh wraps the protocol above.
export KRZYKACZ_TOPIC=<your-topic>
./scripts/krzykacz.sh "Backup finished"
./scripts/krzykacz.sh --voice justyna --repeat 2 "Tests failed"
./scripts/krzykacz.sh --effect game_over "Something broke"
./scripts/krzykacz.sh --effect fight --effect game_over "Multiple sounds, then speech"
./scripts/krzykacz.sh --topic other-topic --server https://ntfy.example.com "Hello"Run ./scripts/krzykacz.sh --help for the full flag list.
Besides ntfy, krzykacz can accept the same messages directly over the local network -- no ntfy relay needed. Both are off by default and run inside the existing process.
Both endpoints are open by default, same trust model as ntfy (the topic
name is the only secret there). Set KRZYKACZ_AUTH_TOKEN to require a
bearer token (Authorization: Bearer <token>) on every request to either
endpoint -- the examples below include it; drop the header if you haven't
set a token.
Every call that actually triggers the light/speaker -- POST /v1/publish,
/v1/random-sound and /v1/random-curse over HTTP, and the
send_message/play_recent_message/random_sound/random_curse MCP tools
-- is limited to one call per source IP per KRZYKACZ_RATE_LIMIT_INTERVAL
seconds (default 10); a caller over that gets 429 (HTTP) or a
"rate limited" text result (MCP) instead of being queued. The limit is
shared between the two transports and across all of these calls, so one IP
can't get two calls in by mixing them. Set it to 0 to disable. Read-only
calls (every GET, and the list_voices/list_effects MCP tools) are never
rate-limited.
Every HTTP request and MCP tool call is also logged one line at a time under
the krzykacz.audit logger name (ip=... action=... ...), so
journalctl -u krzykacz | grep krzykacz.audit shows who called what and
whether it was queued, dropped, rate-limited, or rejected.
Both the limiter and the audit log key on the raw TCP/ASGI source address --
there's no X-Forwarded-For support. Putting a reverse proxy in front of
krzykacz (e.g. to add TLS) would make every real caller show up as the
proxy's own address, collapsing "one call per IP" into one shared budget for
the whole service; this is a home-LAN tool with no reverse proxy in its
supported setup, so that's accepted rather than worked around.
Set KRZYKACZ_HTTP_ENABLED=1. The HTTP API is versioned under /v1.
Accepts the exact same body+Tags shape as the ntfy protocol above:
curl -H "Authorization: Bearer <token>" \
-H "Tags: voice=justyna" \
-d "hello" \
http://192.168.1.50:8123/v1/publishResponds 202 {"status": "queued"}, or 503 {"status": "dropped"} if the
announcer's queue is full (see KRZYKACZ_QUEUE_SIZE), 401 if the token is
missing/wrong, or 429 {"error": "rate limited"} if this source IP already
published within KRZYKACZ_RATE_LIMIT_INTERVAL seconds (see below). Every
GET below is read-only and not rate-limited.
Plays one random sound from the curated soundboard, no speech, no body:
curl -X POST -H "Authorization: Bearer <token>" \
http://192.168.1.50:8123/v1/random-soundResponds 202 {"status": "queued", "content": "<fight>", "voice": null, "speed": null}
-- content is the effect tag that was picked, so you can see what fired.
503 {"error": "nothing to play"} if no curated sounds are installed (see
"Przekleństwa and random sounds" below for what "curated" means), otherwise
the same 401/429 as /v1/publish.
Says one random Polish przekleństwo in a random voice at a random speed, no
body. intensity and style are optional query-string filters:
curl -X POST -H "Authorization: Bearer <token>" \
"http://192.168.1.50:8123/v1/random-curse?intensity=mild&style=funny"Responds 202 {"status": "queued", "content": "Motyla noga!", "voice": "darkman", "speed": 1.05}.
An unset, unrecognized, or too-narrow intensity/style combination widens
back to the full library rather than erroring -- see "Przekleństwa and random
sounds" below for the valid values (also in GET /v1/limits). Same
401/429 as /v1/publish.
Reports what's currently playing and what's still waiting behind it:
curl -H "Authorization: Bearer <token>" http://192.168.1.50:8123/v1/queue{
"playing": {"content": "Uwaga, obiad gotowy", "voice": "justyna", "repeat": 1},
"pending": [{"content": "Backup zakonczony", "voice": null, "repeat": 1}],
"muted": false
}playing is null when the announcer is idle. A queued replay request
serializes as {"replay": -1} instead of content/voice/repeat.
muted is whether the box is currently muted (see below).
The emergency stop. mute silences krzykacz: it refuses anything new,
discards whatever is still queued, and ends the message being played after its
current sound or sentence. unmute restores normal service:
curl -X POST -H "Authorization: Bearer <token>" http://192.168.1.50:8123/v1/mute
curl -X POST -H "Authorization: Bearer <token>" http://192.168.1.50:8123/v1/unmuteBoth respond 200 {"muted": true} / {"muted": false} and are idempotent.
Unlike every other POST they are not rate-limited: this is what you reach
for while the speaker is misbehaving, so it must not be refused because your IP
just published the thing that needs silencing. They are still subject to
KRZYKACZ_AUTH_TOKEN. While muted, /v1/publish and the random endpoints
respond 503 {"status": "dropped", "reason": "muted"}.
Mute is not a mid-word cut: the sound or sentence already handed to the speaker plays to its end (a long one can take several seconds). Mute is also lost on restart -- the service comes back up unmuted.
What can be passed as voice:
curl -H "Authorization: Bearer <token>" http://192.168.1.50:8123/v1/voices{
"tts": "piper",
"voices": ["darkman", "justyna", "jarvis", "meski", "zenski", "gosia", "bass", "mc_speech", "espeak_male", "espeak_female", "..."],
"default_voice": "darkman"
}tts is the primary engine. Under piper the names come from the configured
voice map, and the espeak_* names (see "espeak-ng voices") follow them in the
same list; under espeak the primary voice is an espeak-ng language code. Voices
backed by a multi-speaker model appear here as ordinary names, one per speaker.
What can be named in a <file> tag, anywhere in the body:
curl -H "Authorization: Bearer <token>" http://192.168.1.50:8123/v1/effects{"effects": ["fight", "game_over", "bark01", "8bit00", "..."]}Read fresh from disk on each request, so effects added by
download_effects.sh show up without a restart.
The numbers a client would otherwise hardcode from this README -- how long a
message may be, how far repeat and replay reach, how often it may call:
curl -H "Authorization: Bearer <token>" http://192.168.1.50:8123/v1/limits{
"max_content_bytes": 800,
"max_spoken_bytes": 1600,
"max_repeat": 10,
"max_effects": 16,
"max_effect_repeat": 10,
"history_size": 10,
"queue_size": 10,
"rate_limit_interval": 10.0,
"speed_range": [0.5, 2.0],
"variation_range": [0.0, 1.5],
"rhythm_range": [0.0, 1.5],
"curse_intensities": ["mild", "medium", "strong"],
"curse_styles": ["staropolskie", "modern", "funny", "grim"]
}Set KRZYKACZ_MCP_ENABLED=1. Exposes an MCP server over Streamable HTTP at
http://<host>:8124/mcp, with four tools that trigger the light/speaker:
send_message(content, voice=None, repeat=None, speed=None, variation=None, rhythm=None)-- speak new textplay_recent_message(number=-1)-- replay one of the last few messages instead of resubmitting its textrandom_sound()-- play one random sound from the curated soundboard, no speechrandom_curse(intensity=None, style=None)-- say one random Polish przekleństwo in a random voice at a random speed; see "Przekleństwa and random sounds" below for the validintensity/stylevalues
and four that don't trigger anything (not rate-limited, since they touch neither the lamp nor the speaker):
list_voices()-- voice names, the default, and the TTS backendlist_effects()-- sound-effect filenames usable in a<file>tagqueue_status()-- what is playing, what is queued, and whether it is muted (mirrorsGET /v1/queue)set_mute(muted)-- silence krzykacz or let it speak again (mirrorsPOST /v1/muteand/v1/unmute); never rate-limited, so it is always available as an emergency stop. While muted the triggering tools answerdropped (muted).
The speaking tools' descriptions (and the server's instructions) are
generated from this instance's actual configuration -- the real list of
voice names, the default voice, the TTS backend -- so an MCP client sees
what it can pass to voice without guessing or re-reading this README, and
can call list_voices/list_effects for anything added since the session
started.
This uses the official mcp Python SDK, which is not in
requirements.txt (like piper-tts, it's an optional extra -- install it
only if you use this feature) and requires Python >= 3.10 (Raspberry Pi
OS bullseye ships 3.9; use bookworm or newer for this feature):
pip install mcpFrom an MCP client config (e.g. Claude Desktop / any client that supports a remote Streamable HTTP server with custom headers):
{
"mcpServers": {
"krzykacz": {
"url": "http://192.168.1.50:8124/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}Or talk to it directly with curl (raw MCP JSON-RPC over Streamable HTTP)
-- a session has to be opened with initialize first (most MCP clients do
this automatically), then reused via the Mcp-Session-Id response header
on every following call:
HOST=192.168.1.50:8124
TOKEN=<token>
SESSION=$(curl -si -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
"http://$HOST/mcp" | grep -i '^mcp-session-id:' | tr -d '\r' | cut -d' ' -f2)
curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"send_message","arguments":{"content":"hello"}}}' \
"http://$HOST/mcp"| Variable | Default | Description |
|---|---|---|
KRZYKACZ_TOPIC |
(required) | ntfy topic name |
KRZYKACZ_NTFY_SERVER |
https://ntfy.sh |
ntfy server |
KRZYKACZ_LIGHT |
uhubctl |
uhubctl or null |
KRZYKACZ_UHUBCTL_LOC |
1-1 |
USB hub location |
KRZYKACZ_UHUBCTL_PORT |
2 |
port number |
KRZYKACZ_TTS |
piper |
primary engine: piper or espeak. The espeak_* voices are available either way |
KRZYKACZ_PIPER_DEFAULT_VOICE |
darkman |
default voice name (key in the voice map) |
KRZYKACZ_PIPER_MODEL |
/var/lib/krzykacz/voices/pl_PL-darkman-medium.onnx |
path to the default voice's .onnx model |
KRZYKACZ_PIPER_VOICES |
(empty) | extra voices: name=/path.onnx,name2=/path2.onnx; append :<speaker index> to a value to select one embedded speaker out of a multi-speaker model, e.g. staszczyk=/path/pl_PL-tts-pl.onnx:0 -- see "Piper voices" below |
KRZYKACZ_ESPEAK_VOICE |
pl |
espeak-ng voice used when KRZYKACZ_TTS=espeak |
KRZYKACZ_ESPEAK_VOICES |
the ten espeak_* voices |
espeak-ng voices offered as voice= values, name=spec,name2=spec2 (e.g. espeak_male=pl+m3); an explicitly empty value turns them off -- see "espeak-ng voices" |
KRZYKACZ_ALSA_DEVICE |
(unset = system default) | ALSA device for aplay -- check aplay -l on your Pi: the default card may be HDMI, not the jack, in which case you need something like plughw:1,0 |
KRZYKACZ_PIPER_SPEED |
(unset = piper's default) | instance-wide speed (see Protocol above); a speed tag on a message overrides this |
KRZYKACZ_PIPER_VARIATION |
(unset = piper's default) | instance-wide variation; overridden per message by a variation tag |
KRZYKACZ_PIPER_RHYTHM |
(unset = piper's default) | instance-wide rhythm; overridden per message by a rhythm tag |
KRZYKACZ_EFFECTS |
ffmpeg |
ffmpeg or null |
KRZYKACZ_ASSETS_DIR |
/var/lib/krzykacz/assets |
directory holding sound effect files |
KRZYKACZ_HISTORY |
10 |
how many recent messages to keep in memory |
KRZYKACZ_QUEUE_SIZE |
10 |
max number of messages waiting to be played; anything beyond that is dropped (with a log warning) rather than queued indefinitely |
KRZYKACZ_HTTP_ENABLED |
0 |
set to 1 to enable the HTTP endpoints (POST /v1/publish, /v1/random-sound, /v1/random-curse, /v1/mute, /v1/unmute, plus GET /v1/voices, /v1/effects, /v1/limits, /v1/queue) |
KRZYKACZ_HTTP_HOST |
0.0.0.0 |
HTTP endpoint bind address |
KRZYKACZ_HTTP_PORT |
8123 |
HTTP endpoint port |
KRZYKACZ_MCP_ENABLED |
0 |
set to 1 to enable the MCP endpoint (requires pip install mcp, Python >= 3.10) |
KRZYKACZ_MCP_HOST |
0.0.0.0 |
MCP endpoint bind address |
KRZYKACZ_MCP_PORT |
8124 |
MCP endpoint port |
KRZYKACZ_AUTH_TOKEN |
(unset = no auth) | bearer token required by the HTTP and MCP endpoints when set |
KRZYKACZ_RATE_LIMIT_INTERVAL |
10 |
seconds between calls that trigger the light/speaker, per source IP; shared by HTTP and MCP; 0 disables it |
KRZYKACZ_CACHE_DIR |
/var/cache/krzykacz |
where synthesized audio is cached (see "Audio cache" below) |
KRZYKACZ_CACHE_TTL |
86400 (24h) |
seconds a cache entry stays valid; 0 disables caching entirely |
KRZYKACZ_CACHE_MAX_MB |
200 |
cache directory size cap; oldest entries are evicted first once it's exceeded |
Synthesizing is the slow part of announcing a message -- piper's process
start, model load and inference are what causes the multi-second gap between
the light turning on and sound starting (see Announcer._announce). A
replay, a nightly-identical alert, or simply the same message sent twice
pays that cost again for byte-identical output, so the second time it's
served from KRZYKACZ_CACHE_DIR instead.
The cache key covers everything that decides the audio: the text, the
resolved model/speaker (not just the voice name -- KRZYKACZ_PIPER_VOICES
can remap a name, and an unknown one falls back to the default), and the
speed/variation/rhythm knobs. A piper crash (empty output) is never
cached, so a transient failure doesn't serve silence for the rest of the
TTL. Entries older than KRZYKACZ_CACHE_TTL are dropped on their next
lookup; the directory is also swept for size roughly once an hour, oldest
first, once it exceeds KRZYKACZ_CACHE_MAX_MB.
Every write and read failure (full disk, missing directory, permissions) is
logged and falls back to synthesizing directly -- a broken cache degrades
speed, never breaks playback. The directory can be deleted at any time; it's
rebuilt on demand. Set KRZYKACZ_CACHE_TTL=0 to disable caching altogether.
repeat gets a related optimization on Piper and on the espeak-ng voices (both
produce headerless PCM at the same rate, which concatenates cleanly): the message and " Powtarzam! " are each synthesized once and the
requested number of copies are joined as audio, instead of synthesizing the
whole joined text every time. The separator is cached too, and it's the same
entry across every message in a given voice, so after the first repeated
message it's always a cache hit. This also means a long message with a high
repeat gets truncated to whole copies rather than cut off mid-word.
POST /v1/random-sound / MCP random_sound() picks one file from
KRZYKACZ_ASSETS_DIR at random and plays it with no speech. "Curated" means
a filename with no extension -- download_effects.sh deliberately strips
extensions from the soundboard it installs, so a bare name (fight,
8bit07) is a deliberately-chosen effect, while a name with a dot
(interface-sounds_click_001.ogg) is a leftover from a retired sound pack
that pruning no longer removes. A hand-dropped file with an extension is
still playable via an explicit <name.ext> tag on send_message -- it's
just excluded from the random pool.
POST /v1/random-curse / MCP random_curse(intensity=None, style=None)
says one random Polish przekleństwo, hardcoded in krzykacz/random_picks.py
(not configurable), in a random voice at a random speed:
intensity--mild,medium, orstrongstyle--staropolskie(archaic),modern,funny, orgrim(folk curses wishing harm)
Both are optional and independent; leaving one unset, passing a value that
isn't in the lists above, or a combination that matches nothing (e.g.
style=grim&intensity=mild -- there are no mild grim curses) all widen back
to the full library rather than failing the call, so this endpoint always
says something. The valid values are also in GET /v1/limits as
curse_intensities/curse_styles.
Eight ready-made single-speaker Polish voices, Piper format (.onnx +
.onnx.json), all 22050 Hz (see below for 8 more from one multi-speaker model):
darkman(default),gosia,bass,mc_speech--rhasspy/piper-voices, the official Piper voice collection on HuggingFace.justyna,jarvis,meski,zenski--csukuangfj/vits-piper-pl_PL-*_wg_glos-medium, the same Piper format, packaged via the sherpa-onnx mirror (the "wg_glos" voice family).
(rhasspy/piper-voices also has mls_6892 for Polish, but it's a lower-quality
16 kHz model -- our fixed 22050 Hz pipeline doesn't support it out of the box, so
it's left out.)
hvsr-robotics/tts-pl-piper-v2
bakes 8 named speakers into a single 22050 Hz .onnx file (fine-tuned from
pl_PL-darkman-medium on Wolne Lektury audiobook narration, CC BY-SA 4.0).
download_voices.sh fetches it as pl_PL-tts-pl.onnx, but unlike the voices
above, one file isn't one voice here -- each speaker needs its own
KRZYKACZ_PIPER_VOICES entry pointing at the same file with a different
:<speaker index> suffix (see krzykacz.tts.VoiceSpec /
krzykacz.config._parse_voices, which parse that suffix and pass it to
piper --speaker <index> at synthesis time):
KRZYKACZ_PIPER_VOICES="staszczyk=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:0,\
krzyzowski=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:1,\
masiak=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:2,\
bielenia=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:3,\
proszek=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:4,\
faszczewska=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:5,\
glogowski=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:6,\
kopa=/var/lib/krzykacz/voices/pl_PL-tts-pl.onnx:7"(Index-to-name mapping straight from the model card: 0 Jan Staszczyk, 1
Radosław Krzyżowski, 2 Wojciech Masiak, 3 Bartosz Bielenia, 4 Marek
Proszek, 5 Katarzyna Faszczewska, 6 Bartosz Głogowski, 7 Piotr Kopa;
this repo's names above drop diacritics and use surnames only, to stay
consistent with the plain-ASCII, single-word style of the other voice
names and to avoid the two Bartoszes colliding.) These voices then show up
in GET /v1/voices and the MCP tool descriptions exactly like the eight
above -- one flat list of voice names, regardless of how many .onnx files
back them.
Download (idempotent -- skips files already on disk):
./scripts/download_voices.sh
# or to a different directory:
./scripts/download_voices.sh /path/to/voicesThe default target directory is /var/lib/krzykacz/voices, matching the default
paths in KRZYKACZ_PIPER_MODEL / KRZYKACZ_PIPER_VOICES above.
Ten more voices come from espeak-ng
(apt install espeak-ng), a formant synthesizer -- no model files to download,
and it is quick even on a Pi 3. It sounds robotic next to Piper, which is
rather the point for a few of them. Each is the Polish voice plus one of
espeak-ng's male/female/character variants; the wire names are aliases because
the voice alphabet has no + in it.
voice |
espeak-ng spec | |
|---|---|---|
espeak_male |
pl+m3 |
male |
espeak_male2 |
pl+m1 |
male |
espeak_male3 |
pl+m7 |
male |
espeak_female |
pl+f3 |
female |
espeak_female2 |
pl+f1 |
female |
espeak_female3 |
pl+f5 |
female |
espeak_whisper |
pl+whisper |
whispered |
espeak_croak |
pl+croak |
croaky |
espeak_announcer |
pl+announcer |
public-address style |
espeak_robot |
pl+klatt2 |
robotic |
They are on by default. Change the set with KRZYKACZ_ESPEAK_VOICES
(espeak_deep=pl+m2,espeak_kid=pl+f4; espeak-ng --voices=variant lists what
your build has) or turn them off with KRZYKACZ_ESPEAK_VOICES=. A name that is
also a Piper voice goes to Piper. speed, variation and rhythm apply as
described under Protocol.
Both engines render 22050 Hz mono, which is what lets one voice list serve
both and lets repeat join audio from either. Their output is cached like
Piper's (see "Audio cache").
Three packs, plus one sound synthesized by the download script, all CC0 (public domain, no attribution required): the Voiceover
Pack (Fighter) and Music Jingles from kenney.nl, and
80 CC0 creature SFX by
rubberduck on OpenGameArt. 211 .ogg files, plus a synthesized knock (three knocks on a door, generated with ffmpeg), stored without an
extension so the tag you type is short -- <fight>, <8bit00>, <bark01>.
Full list of names in SOUNDS.md, or read live from
GET /v1/effects (the list_effects MCP tool reports the same thing).
Download (idempotent, copies files without conversion -- KRZYKACZ_EFFECTS=ffmpeg
plays any format ffmpeg can decode):
./scripts/download_effects.sh
# or to a different directory:
./scripts/download_effects.sh /path/to/assetsThe default target directory is /var/lib/krzykacz/assets, matching the default
KRZYKACZ_ASSETS_DIR above. Any file you drop into that directory by hand also
becomes usable in a <...> tag under its own filename.
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
KRZYKACZ_LIGHT=null KRZYKACZ_TTS=espeak KRZYKACZ_TOPIC=<your-topic> python -m krzykacz# on the Pi, after rsync-ing the repo to /home/pi/krzykacz and creating
# /etc/krzykacz.env (see Configuration above):
sudo ./scripts/install-service.shThis creates a dedicated, unprivileged krzykacz system user (no login shell, no
sudo) and installs systemd/krzykacz.service to run as that user rather than root.
The only two things the service touches that normally require privilege -- the USB
hub (uhubctl, for the light) and the audio device (aplay) -- are granted via a
narrow udev rule and the audio group, both set up by the script. Re-run it any time
after pulling an update; it's idempotent.
The code is copied to /opt/krzykacz, which is what the service actually runs
from. Home directories are mode 0700 on Raspberry Pi OS, so a service user can't
traverse into one -- installing outside /home is better than loosening those
permissions, and it lets the unit use ProtectHome=yes to hide /home from the
service entirely. Voices and sound effects live under /var/lib/krzykacz/ for the
same reason.
The venv is not managed by the script (onnxruntime wheels are slow to build on a Pi, so it's preserved across runs). Create it once:
sudo python3 -m venv /opt/krzykacz/venv
sudo /opt/krzykacz/venv/bin/pip install -r /opt/krzykacz/requirements.txt piper-tts
# only if using the MCP endpoint (KRZYKACZ_MCP_ENABLED=1, needs Python >= 3.10):
sudo /opt/krzykacz/venv/bin/pip install mcpThe espeak_* voices and the knock sound need two system packages, neither
of which the install script pulls in: sudo apt install espeak-ng ffmpeg.
systemctl status krzykacz
journalctl -u krzykacz -fpip install pytest
python -m pytest