Language: speaker recognition itself works for any language. The spoken prompts of guided enrollment come in Polish and English (following the HA language); the "Who is speaking?" and announcement keywords live in Assist Router, which currently understands Polish.
Assist turns speech into text, but it does not know who is speaking. Voice satellites (Home Assistant Voice PE, ESPHome) send the command without a user, so to Home Assistant every sentence is anonymous. This becomes a problem with first-person questions:
Whisper and other speech-to-text models do not recognize the speaker. Diarization (e.g. WhisperX) only answers "who spoke when" with anonymous labels, not "which household member is this". Assist Speaker ID adds the missing piece: it turns a voice into a "fingerprint" (a vector of numbers), compares it with the stored voiceprints of household members and passes the result on – to the conversation agent, to an LLM and to automations.
The project has two parts in one repository:
| Part | What it does | Installation |
|---|---|---|
| wyoming-speaker-id service | A Wyoming proxy in front of your speech-to-text server (e.g. Whisper). It forwards the audio unchanged and, in parallel, computes the voice fingerprint and adds the speaker to the result. | HA add-on or a Docker container on another computer |
| Assist Speaker ID integration | Speech-to-text entity "Speech to text with speaker", wrapper agent "Agent with speaker", sensor "Last speaker", actions for enrollment, calibration and announcements. | HACS (custom repository, type "Integration") |
It gives the most when paired with Assist Router: it uses the speaker for first-person questions, the speaker's room, family terms ("my brother"), answers to „Kto to mówi?” (“Who is speaking?”) and announcements. Assist Speaker ID works with any agent, though.
speaker (person id or unknown), speaker_name,
speaker_score, speaker_scores (scores of all people),
speech_seconds.get_speaker(), and by automations via the sensor and the event.Why a proxy and not a separate model alongside? The Wyoming client in HA reads only the text from the result, and the speech-to-text entity does not know which satellite the recording came from. So the service adds the speaker to the transcription result, and the integration has its own client that reads these fields. A regular Wyoming client ignores the extra fields, so the service can safely replace Whisper: in the worst case it behaves like plain Whisper.
Before choosing, we compared several models available in sherpa-onnx. Test sets: 6 Polish Piper voices × 12 sentences, and recordings of 3 people from the sherpa-onnx examples. Accuracy was measured with leave-one-out. EER (equal error rate) is the point where false accepts equal false rejects – lower is better.
| Model | Dimension | 3 s of speech | Piper: accuracy / EER | People: accuracy / EER |
|---|---|---|---|---|
| 3D-Speaker CAM++ zh_en "advanced" (default) | 192 | 91 ms | 100% / 0.2% | 100% / 0% |
| NeMo TitaNet-small | 192 | 59 ms | 100% / 0.9% | 100% / 0% |
| 3D-Speaker ERes2Net (VoxCeleb) | 192 | 218 ms | 100% / 1.9% | 100% / 0% |
| WeSpeaker ResNet34-LM | 256 | 257 ms | 98.6% / 12.4% | 92.9% / 15.4% |
| 3D-Speaker CAM++ (VoxCeleb) | 512 | 90 ms | 68.1% / 40.8% | 71.4% / 37.7% |
Through the whole chain (Piper → wyoming-speaker-id → Whisper), every utterance with at least
1 s of speech was assigned to the right person, with scores of 0.76–0.92. A different model can be
set with the model option (a sherpa-onnx model name or a path to an .onnx
file); after changing the model, voiceprints must be recorded again.
Proven in practice: the author runs the service daily on an NVIDIA Jetson AGX Orin 64 GB, as a container next to faster-whisper (Whisper on the GPU, voice model on the CPU), for the whole family and 7 satellites. It works very well: recognition takes about 90 ms and runs while transcription is in progress, so commands are no slower than with Whisper alone.
The simplest route when Home Assistant runs as HA OS or Supervised.
https://github.com/witold-gren/home-assistant-assist-speaker-id.tcp://core-whisper:10300 for the Whisper add-on, or the address of your own server,
e.g. tcp://192.168.1.10:10300.| Add-on option | Default | Meaning |
|---|---|---|
upstream | tcp://core-whisper:10300 | Wyoming speech-to-text server |
threshold / margin | 0.5 / 0.05 | shared threshold and lead over the runner-up (initial values) |
min_speech / max_speech | 1.0 / 8.0 s | shorter speech gives "unknown"; how much speech to use |
threads | 2 | model CPU threads |
model | CAM++ zh_en | another sherpa-onnx model (name or path) |
api_token | random | your own API token (usually not needed) |
debug | off | verbose log |
When Whisper runs on a separate machine, it is best to run the service next to it. The audio then does not cross the network twice.
git clone https://github.com/witold-gren/home-assistant-assist-speaker-id
docker build -t wyoming-speaker-id home-assistant-assist-speaker-id/speaker_id/
docker run -d --name speaker-id --restart unless-stopped \
-p 10350:10350 -p 10351:10351 -v speaker-id-data:/data \
-e SPEAKER_ID_UPSTREAM=tcp://192.168.1.10:10300 \
-e SPEAKER_ID_API_TOKEN=dlugi-losowy-token \
wyoming-speaker-id
The same container in docker-compose.yaml, next to Whisper:
services:
whisper:
image: rhasspy/wyoming-whisper
# … your existing configuration
speaker-id:
build: ./home-assistant-assist-speaker-id/speaker_id
restart: unless-stopped
ports: ["10350:10350", "10351:10351"]
volumes: ["./speaker-id/data:/data"]
environment:
SPEAKER_ID_UPSTREAM: tcp://whisper:10300
SPEAKER_ID_API_TOKEN: dlugi-losowy-token
| Variable | Default | Meaning |
|---|---|---|
SPEAKER_ID_UPSTREAM | (required) | Wyoming speech-to-text server |
SPEAKER_ID_THRESHOLD / SPEAKER_ID_MARGIN | 0.5 / 0.05 | shared threshold and margin |
SPEAKER_ID_MIN_SPEECH / SPEAKER_ID_MAX_SPEECH | 1.0 / 8.0 | minimum speech and how much speech to use (seconds) |
SPEAKER_ID_MODEL | CAM++ zh_en | sherpa-onnx model name or path |
SPEAKER_ID_API_TOKEN | none | HTTP API token; without it, keep port 10351 strictly on your home network |
SPEAKER_ID_THREADS, SPEAKER_ID_DEBUG | 2, 0 | model threads, verbose log |
Port 10350 is Wyoming (speech-to-text with speaker), port 10351 is the HTTP API (voiceprints, calibration, last recording).
The integration creates an "Assist Speaker ID" device with the speech-to-text entity "Speech to text with speaker", the agent "Agent with speaker" and the sensor "Last speaker".
The integration options (Configure) are a menu with two entries:
person.*),
the target agent for "Agent with speaker", the prompt line (default
The person speaking is {name}.) and whether to run commands as the speaker's HA user
(context.user_id).Settings → Voice assistants → pick the pipeline (assistant) of each satellite:
Why turn off local handling? When it is on, HA tries its own intents before asking the agent. The built-in broadcast intent would then catch „Ogłoszenie, …” (“Announcement, …”) and read the text with speech synthesis instead of playing your recording, and during enrollment the agent would not get to reply with a short "Recorded 3 of 10". Assist Router and "Agent with speaker" pass everything HA understands back to HA anyway, so regular commands keep working.
Switch satellites one at a time. If something does not work, just switch the pipeline back to plain Whisper.
Check: Settings → Voice assistants → pipeline → ⋮ → Debug. After a command from a satellite, the
"Last speaker" sensor shows unknown (no voiceprints yet), and the
speech_seconds attribute shows the length of speech. That means the audio is passing
through the service.
Each household member records 10–15 ordinary sentences. The easiest way is guided enrollment: the satellite asks for each next sentence itself, no wake word needed.
speaker_id.start_enrollment:
action: speaker_id.start_enrollment
data:
speaker: anna # person id: lowercase, no spaces
name: Anna # name that is spoken and displayed
count: 10
satellite: assist_satellite.salon # the satellite Anna is standing next toEnrollment also works without the satellite field, but then each sentence starts
with the wake word. Progress is shown in the enrollment attribute of the "Last speaker"
sensor. To cancel: speaker_id.stop_enrollment.
Enrollment went wrong? Add more samples with the same action (new ones are added
to the old ones) or start over: speaker_id.delete_speaker and enroll again. You can add
a single sentence afterwards with speaker_id.add_last_sample: the last utterance (at
most 10 min old) becomes a sample of the given person.
Good samples: record through the satellite and from the distance the person usually speaks from – the microphone and the room change the voice more than the words do. Use ordinary commands of a few words, in a normal tone (shouting, whispering and laughing lower the scores). Enroll everyone in the household before you calibrate. Children's voices change as they grow, so add new samples for them every few months.
Each utterance is compared with each person's voiceprint. The score is the similarity, usually from 0 to 1 (given as a percentage in the answer to „Kto to mówi?”):
| Similarity | What it usually means (CAM++, satellite microphone) |
|---|---|
| 0.75–0.95 | same person, good conditions |
| 0.55–0.75 | same person, but short, far away, noisy or in a different tone |
| 0.35–0.55 | similar voice, often siblings or a parent and child of the same sex |
| 0–0.35 | a different person |
This is not a measure of how "unique" a voice is. A high score only means that this utterance is very similar to this voiceprint.
The best-matching person is recognized only if both conditions hold:
Otherwise the speaker is "unknown" (unknown), and Assist behaves as if there were no
recognition. A wrong guess is worse than no recognition, so the system is cautious by default.
| Parameter | Default | Where to change it |
|---|---|---|
| Shared threshold | 0.50 | integration options → Recognition thresholds |
| Person's threshold | none = shared | integration options, action speaker_id.set_speaker, calibration with apply: true |
| Margin | 0.05 | integration options |
| Minimum speech | 1.0 s | integration options; a shorter utterance is always "unknown" |
Values from the integration options are stored in the service (speakers.json) and
take precedence over add-on options and environment variables, which are only initial values.
The speaker_id.calibrate action compares each person's samples with their voiceprint
and with the other people's samples, then suggests a threshold. With apply: true it
saves the per-person thresholds right away.
| Report field | Meaning |
|---|---|
samples | number of samples |
own_min | the worst own sample: similarity to the voiceprint built from the person's other samples |
own_p10 | 10th percentile: 9 out of 10 own samples are at least this similar |
own_mean | average similarity of own samples |
others_max | the most similar sample of someone else |
separable | true when own_min > others_max: no one else's sample came close to the worst own sample |
suggested_threshold | midpoint between own_p10 and others_max (within 0.2–0.9) |
Example (Tomek, adult):
samples: 10
own_min: 0.709 # worst own sample: 71% similar to the voiceprint
own_p10: 0.778 # 9 out of 10 own samples have at least 78%
own_mean: 0.803 # 80% on average
others_max: 0.305 # most similar sample of someone else: only 31%
separable: true # 0.709 > 0.305 - voices clearly separated
suggested_threshold: 0.541 # (0.778 + 0.305) / 2
There is a 40-point gap between the worst own sample (71%) and the closest one from someone else
(31%). Any threshold between roughly 0.35 and 0.70 will work, and the suggested 0.54 sits in the
middle with headroom on both sides. For a child you might get e.g. own_p10: 0.74 and
others_max: 0.68 (a sibling): a threshold of 0.71 and only a 6-point gap. Then it is worth
raising the margin as well.
others_max is computed only from people
who have voiceprints.separable: false the culprit is often a single bad sample (someone talked
over it, noise). Add samples or re-enroll that person.others_max and own_p10: closer to
own_p10 means fewer mistakes, closer to others_max – fewer "unknowns".Calibration with apply: true overwrites per-person thresholds. Make
manual tweaks after it.
With Assist Router, ask „Kto to mówi?”, „Kim jestem?” (“Who am I?”), „Czy mnie rozpoznajesz?” (“Do you recognize me?”) or „Czyj to głos?” (“Whose voice is this?”). The answer gives the person and the similarity, e.g. „Mówi Anna, podobieństwo 82%. Następna osoba: Kasia, 41%.” (“Anna is speaking, similarity 82%. Next person: Kasia, 41%.”). It is the quickest test of thresholds and voiceprint quality.
Set it in the pipeline and choose the target agent in the integration options (e.g. Ollama, OpenAI, HA's built-in agent). Before passing the command on, "Agent with speaker":
context.user_id to the speaker's HA user, so the history shows who
switched the light;get_speaker()try:
from custom_components.speaker_id.api import get_speaker
except ImportError:
get_speaker = None
speaker = get_speaker(hass) if get_speaker else None
if speaker and speaker.speaker:
... # speaker.speaker (id), .name, .person, .user_id, .score
An agent that handles enrollment itself calls enrollment_reply(hass) first: it
returns a short reply ("Recorded 3 of 10. Next sentence.") and whether to keep listening, or
None for a regular utterance. Assist Router does this: a first-person question without a
name ("what classes do I have") refers to the speaker's data, "here" is the speaker's
room, and "my brother" is a specific family member.
The "Last speaker" sensor (state: name or unknown; attributes:
speaker, name, person, score,
scores, speech_seconds, enrollment) and the
speaker_id_identified event. The utterance text is deliberately kept out of the
history.
HA runs automations with a sentence trigger before the agent, so they do not get the speaker
directly. The sensor is updated in the same command, just before them, though – a result from a few
seconds ago is the speaker of this sentence. A handy macro in
/config/custom_templates/mowca.jinja (mowca is Polish for "speaker"; you
can rename it):
{% macro mowca(max_s=20) -%}
{%- set s = states.sensor.assist_speaker_id_last_speaker -%}
{%- if s and (now() - s.last_updated).total_seconds() <= max_s -%}
{{- s.attributes.speaker or '' -}}
{%- endif -%}
{%- endmacro %}
After creating the file, run the homeassistant.reload_custom_templates action.
Example: "Where is my phone?" rings the phone of the person asking:
triggers:
- trigger: conversation
command: "where is my phone"
actions:
- variables:
who: "{% from 'mowca.jinja' import mowca %}{{ mowca() }}"
- if: "{{ who in ['anna', 'tomek'] }}"
then:
- action: script.ring_phone # your script, e.g. ringing via the HA app
data: { person: "{{ who }}" }
- set_conversation_response: "Ringing your phone."
else:
- set_conversation_response: "I don't know who is asking. Whose phone should I find?"
The sensor id depends on the HA language; in Polish it is
sensor.assist_speaker_id_ostatni_mowca.
„OK Nabu, ogłoszenie, dzieci, proszę przyjść na obiad.” (“OK Nabu, announcement, kids, please come to dinner.”) The other speakers play your recording – exactly what you said after the wake word – instead of reading the text with speech synthesis. You can hear who is calling and in what tone.
/api/speaker_id/announcement/<token>, valid for 10 minutes. Nothing is written
to disk.speaker_id.announce_last action in an
automation:| Field | Default | Meaning |
|---|---|---|
satellites | all satellites that support announcements | where to play |
exclude | none | satellite to skip, usually the one that recorded |
media_players | none | extra players, e.g. speakers via Music Assistant (play_media with announce: true) |
preannounce | true | short chime before the recording |
max_age | 60 s | an older utterance is not announced |
A conversation agent can do the same with
async_announce_last(hass, exclude=…, media_players=…) from
custom_components.speaker_id.api. Remember to turn off local handling in the pipeline
(section 6).
| Action | What it does |
|---|---|
speaker_id.start_enrollment | records a voiceprint: speaker, name, count, optionally satellite (guided enrollment) |
speaker_id.stop_enrollment | cancels enrollment |
speaker_id.add_last_sample | the last utterance (at most 10 min old) as a person's sample |
speaker_id.calibrate | calibration report and suggested thresholds; apply: true saves the thresholds |
speaker_id.set_speaker | changes a person's name and own threshold |
speaker_id.delete_speaker | deletes a person's voiceprint |
speaker_id.list_speakers | list of people with sample counts and thresholds |
speaker_id.announce_last | plays the last utterance as an announcement |
The service also has an HTTP API (port 10351, header Authorization: Bearer <token>),
useful for scripts and tests:
| Request | What it does |
|---|---|
GET /api/info, GET/PUT /api/settings | version and model; thresholds, margin, minimum speech |
GET /api/speakers, PUT/DELETE /api/speakers/{id} | list of people; change or delete a person |
POST /api/speakers/{id}/samples | sample from a WAV file |
POST /api/speakers/{id}/samples/last, DELETE …/samples/{n} | last utterance as a sample; delete a sample |
POST/DELETE /api/enrollment | start and stop enrollment |
GET /api/last, GET /api/last.wav | result and recording of the last utterance (from RAM) |
POST /api/identify | identify the speaker of an uploaded WAV file |
POST /api/calibrate | calibration report |
This is a convenience, not security. A voice can be recorded and replayed, and siblings can sound very much alike. Do not use the speaker to unlock doors, disarm an alarm or anything else that requires authentication.
A voiceprint is biometric data (GDPR, Article 9). Therefore:
/data/speakers.json), no
recordings (a vector describes the timbre of a voice, but is not a recording);add_last_sample and announcements);delete_speaker removes a person's voiceprint completely;