sessionvoice (the ears and the mouth)
sessionvoice is the distribution and the Claude Code plugin. The commands
and modules keep their names: ears, ears-serve, mouth, src/ears,
src/mouth.
One listening engine for VoiceMode and the Delta bot. It is always listening, streams transcription, and decides when he has finished.
PCM (s16le mono) -> backend ----------> events (docs/events.md) -> stdout
apple | nemotron | whisper ^
VAD (Silero) ----- edges ----------+
turn stage (silence | smart) -- turn, preview
- Backends, all speaking one event format:
apple: SpeechTranscriber on macOS 27, through theapple-speechSwift helper.nemotron: Nemotron 3.5 streaming on MLX, run as a child process.whisper: any OpenAI-compatible transcription URL, for Linux or pre-27 macOS.
- One VAD (Silero, falling back to energy). It produces
vadedges, and Nemotron segments on them too. - Turn stages:
silence(the bot's join rules, plus commit) andsmart(Cora's Smart Turn and Parakeet re-decode), both in-process with the audio.
Components
The engine above is the core. Around it, these parts make it an always-on
pair of ears on a Mac (m5, 2026-10-01; depth in docs/architecture/):
- Capture (
src/ears/listen):ears.listen capturereads one input device and writes every partial, turn and heartbeat to the heard log (src/ears/heard.py):~/.voicemode/logs/conversations/heard_YYYY-MM-DD.jsonl.- A speaker change ends the turn (
--speaker-split K, EARS-14): when another named speaker holds K partials in a row (by the speaker side-car'sspeakers_YYYY-MM-DD.jsonl), the capture closes the turn before them and opens a new one, so each heard turn has one speaker. A split turn carriesspeaker(the side-car's label:alice,mike, ...). - Every turn the side-car named carries
speaker(EARS-106): an unsplit turn is named by the same rule, from its own partials' labels (the one name that held K in a row; only a turn of fewer than K partials, too short to reach K at all, is named by its named labels if they are all one name; a longer turn no name held K in a row, such as a mostly-neutral TV turn with one straymike, carries none), and markedspeaker_from: labels. A turn of neutral labels only (the TV), no labels yet, or a change the split had not cut when the turn closed, carries none. Default 2; 0 = off (nospeakeron any turn: today's turns exactly). No side-car file: turns as before. Labels that name nobody (other,mixed,silent,unheard) never count. - Deploy turns it on.
keeper/ears-keeper.shstarts the capture without--speaker-split, so the default K=2 goes live at the next capture restart after merge. Rollback: add--speaker-split 0to the capture line instart_captureand restart the capture.
- A speaker change ends the turn (
- Keeper (
keeper/ears-keeper.sh, launchdcom.failmode.ears-keeper): keeps the capture alive on a device that comes and goes. It follows the Mac's default input throughkeeper/input-watch.swift, and when the AirPods leave or return it stops the capture and starts one on the right device. It only manages the capture a keeper started; other captures (a call-tap, a call leg) are left alone. - Sounds (
keeper/ears-sounds.py, launchddev.voicemode.ears-sounds): a short sound in his ears when the ears log an event, so he knows he was heard. - Turn mail (
keeper/ears-mail.py, launchddev.voicemode.ears-mail, EARS-6): mails every finished turn of his To and Fromheard+<device>@<host>, with theEars-*headers of the pager genie's contract (sessionmailopenspec/changes/pager-genie). A session hears a device by subscribing to that address. The box isheard(--sender; Mike, 16:13 Thu 2026-10-01), becauseearsis this project's own box. sessionmail trusts only its keeper's From (_voice.KEEPER), so the plist loads only once that saysheardtoo.--to seatsis the older mail, To each seat a turn is for. Never run--to seatsand--to devicefor the same seat: it would get each call twice.Ears-Speakeris whose turn it is. A turn with aspeakerlabel (a split turn, EARS-14, or any turn the side-car named, EARS-106) is mailed as<label>@<host>:alicebecomesalice@m5, the same form as--speaker. The label is lower-cased, and any character outside[a-z0-9._-]becomes-. Every other turn, including one whose label is empty or names nobody, keeps--speaker($EARS_SPEAKER, elseunknown@<host>: a voice the matcher could not place is never mailed as Mike, EARS-110).Ears-Echosays a mouth line heard back through the mic was cut out of the turn (EARS-98). On the speakers the mic hears the mouth, so a line's words are cut wherever they sit in his turn: at its start (up to3 + 5 words/s × the seconds the line began after his turnof his words ahead of it), in the middle, or at the end. The aim is to cut only the echo, never his words around it. That is what the fixtures test (8 real echo turns, 8 AirPods quotes), not a guarantee: the cut is a text match. A span starts on 2 or more matched words in a row, or on a lone word joined to them by one-for-one swaps (a misheard name), so a lone "the" of his does not drag his words into a clipped echo. Inside a span, up to 30% unmatched words (half, when the span is a word-for-word copy of the line) are taken as misheard echo. The header is ONE line, a comma list with an entry per line cut, in turn order:Ears-Echo: cora/2d492fb06d3b 31 words cut, cora/6c681f74374e 30 words cut(<seat>/<utt> <n> words cut; the seat is the line's agent). It goes on both the device mail and the--to seatsmail; the seats mail also keeps its "heard whole" section. A mail with no cut has noEars-Echo. A turn that is all echo is still not mailed. Neither is one word left BETWEEN two cuts: that is the seam misheard ("86%" for "eighty-six"). A lone word before or after both cuts is his and is mailed. Timing uses the line's play span (said−played_s..said) and the turn's real span (the stream start, from the heartbeat orlisten-started, +t0..t1), elsets − (t1 − t0). Three guards keep his quotes of a line: a line played on headphones (AirPods, as the mouth'sBARGE_DEVICES) is never cut; a cut needs half the line's played words; and, only when the stream start is known (the real span), the turn must have opened within 2 s of the line beginning, unless the turn is mostly the line. On thets − (t1 − t0)estimate, only the first two guards apply. Numerals (9%for "nine percent") still slip past the word match. - Waiter (
bin/heard-first): an agent blocks on the heard log until a line is for it, by--rule: its name in a partial or a turn (partial:REGEX,turn:REGEX), a reply to its last line (reply:AGENT), or his first words (first); then it wakes.bin/ears-turnthen reads the whole turn, one line. - Mouth (
src/mouth,bin/mouth): an agent speaks through one queue, and its lines go into the same log, so the waiters can gate out echo. - Calls:
bin/call-tapputs a call from any app into the ears, both ways;keeper/call-earsgrows an ear for each Delta call and drops it at hang-up. The ears keeper runs it as its child: one launch agent,com.voicemode.ears-keeper(ears services install), holds the room's ear and every call's. - Speaker names (not in this repo yet): a sidecar tags each heard line
with who spoke, in
speakers_YYYY-MM-DD.jsonlbeside the heard log, and the waiters read it. Moving it into VoiceMode as an opt-in service is VM-2326.
Use
ears backends # what runs here
ears install # fetch Silero (the engine: Parakeet on mlx-audio :8890)
ears install --apple # also build the Apple helper (swift, macOS 27) + assets
ears file talk.wav --realtime --turns silence --previews
rec ... | ears listen --turns silence # live
ears serve 2023 # a whisper-compatible URL on Apple STT
In Python:
from ears.cli import Pipeline
p = Pipeline("auto", turns="silence", previews=True, emit=my_handler)
p.feed(pcm_bytes) # every 20 ms
p.close()
Install
The distribution is sessionvoice (sessionvoice[nemotron], sessionvoice[all]);
it installs the ears, ears-serve and mouth commands.
uv venv && uv pip install -e '.[all]' # onnxruntime + mlx-audio
ears install
Tests
One command runs the whole suite, offline:
make test # uv run --locked pytest
make test ARGS="-k mouth" # pytest flags pass through
uv sync (or make sync) once installs the dev group from uv.lock; after
that nothing in the suite touches the network, and nothing plays a sound.
Without sounddevice (PortAudio) the four tests/test_ears_sounds_call.py
tests skip: uv sync --extra capture brings it (and onnxruntime, for the
silero test), and then they run, and three of them play a short tick through
the default output. Without VoiceMode on PYTHONPATH its
three tests skip.
Measured (m5, 2026-09-19; Cora's eval set, her score.py, whisper normaliser)
WER through the ears pipeline:
| backend | 20 turns (1858 w) | 150 singles | how |
|---|---|---|---|
| apple | 1.51% | 2.16% | fast feed, finals joined |
| apple | 1.35% | - | one 827 s call, real time |
| nemotron | 3.01% | 3.79% | fast feed, finals joined |
- The real-time call is the 20 turns joined with 3 s gaps, using silence turns with commits on. It produced 22 turns for 20 inputs.
- The old helper, which finalised the live analyzer at each commit, scored 5.17% on the same call, with 54 deletions.
- Turn latency on turn01, fed in real time:
- Apple fires 1.82 s after speech ends, including a 0.2 s commit wait.
- Nemotron fires at 1.65 s.
- VAD edges lag about 140 ms at onset and about 280 ms at offset (a 0.2 s offset debounce).
- Memory: Apple uses about 75 MB of RSS on our side; Nemotron about 2.2 GB.
- Turn stages (Cora, 20 turns, real time):
silencecut 1 of 20, with a p50 of 1.59 s.smartveto cut 0 of 20, with a p50 of 2.43 s.
The mouth (src/mouth)
The speaking half: mouth say queues a line (a maildir), a resident
player speaks it through Kokoro or a cloned voice, to the room or into a
Delta call, and writes saying/said rows to the heard log so the
waiters can tell echo from speech. Design: src/mouth/DESIGN.md.
mouth say --voice pip --device default "Hello."
mouth say --voice pip --device call --hold turn-end "On the call."
mouth --help # stop, retract, amend, status, devices, serve
Moved here from VoiceMode feat/mouth-say @ 64cccd80 (Mike, 00:43 Mon
2026-09-28: "we put the mouth with the ears"). The live player still runs
the VoiceMode copy until cutover. Three ties to VoiceMode remain, and
they come over with the capture:
voice_mode.heard: the heard log's reader and writer, shared with the capture.voice_mode.voice_profiles: resolves cloned voices (pip,laurie).voice_mode.dj: optional, loaded only for music ducking.
Tests: make test ARGS=tests/mouth (see Tests above); put a VoiceMode checkout
on PYTHONPATH to run the three tests that need it.
Metadata
Release files for sessionvoice 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sessionvoice-0.1.0.tar.gz | 290.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sessionvoice-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 507.2 kB
Release files / sessionvoice-0.1.0.tar.gz
| Download URL | sessionvoice-0.1.0.tar.gz |
|---|---|
| Size | 290.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
05cf2951cc71ac1a0c869ea9d7b72f8ef58d41dea500a343d619a804511432a5
|
|
BLAKE2b-256 checksum How to use checksums |
678f3944c1e3e88c9f621aa9f008a301242af073abc58f862fbe32d04321e5dc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / sessionvoice-0.1.0-py3-none-any.whl
| Download URL | sessionvoice-0.1.0-py3-none-any.whl |
|---|---|
| Size | 216.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d5e28a5f0e73268b4ae71e409580732443d9c2109d26c5d7de96980fd4c5c97e
|
|
BLAKE2b-256 checksum How to use checksums |
6eddd9bb39f516eeaaab715d397d73ff7e3b7d8a898e6684b18701ec64ae21e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|