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 coding agents and the Delta bot. It is always listening, streams transcription, and decides when the speaker 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(Smart Turn and a Parakeet re-decode), both in-process with the audio. - Ducking: music dips while the assistant speaks, then comes back. That is
music the mouth plays itself, and your own mpv player if you turn it on
(
$MOUTH_DUCK); other apps keep their volume.mouth duck offturns it off for a while. - Barge-in: on headphones or a call, talk over the assistant and its voice dips at once; a couple of your own words, or "stop", cut it off (barge-in).
Requirements
-
macOS for the always-on parts. The keeper follows devices through CoreAudio (it builds
keeper/input-watch.swiftwithswiftc,keeper/ears-keeper.sh:104), andears services installwrites launchd agents to~/Library/LaunchAgents(src/ears/services.py:93). Theapplebackend needs macOS 27, Xcode'sswiftand a source checkout (uv run ears install --apple: the wheel does not shipswift/, and--applewithout it refuses). The engine alone (ears file,ears listenwithwhisper) needs no Mac. -
Python 3.10-3.14 (
requires-pythoninpyproject.toml) and uv:make testrunsuv run --locked. -
A speech server on
127.0.0.1:8890: mlx-audio's server, which serves both STT and TTS. It is the ears' default STT (Parakeet;src/ears/listen/stt.py:86,src/ears/services.py:530) and the mouth's Kokoro voices (src/mouth/backends.py:289).ears services installfinds one or installs its own (src/ears/speech.py), and asks before it acts, in this order:- Opt-out.
final.urlin~/.sessionkit/voice/settings/ears.json(orEARS_STT_URLat install) names another engine, and the mouth is not being installed::8890is not sessionvoice's to manage. - Use. Something listens on
:8890and says it is mlx-audio:GET /returns mlx-audio's welcome line, or/openapi.jsonlists its audio routes. sessionvoice uses that server, whoever installed it (VoiceMode, for example), and starts nothing. - Refuse. Something else holds
:8890, or holds it and does not answer. Install starts nothing, replaces nothing, and names the holder. When the ears hear through:8890it exits 2 and says what to run; when only the mouth does, it warns and the mouth usessay. Free the port, or pointfinal.urlat another speech server. - Never a second. Nothing answers on
:8890, but a loaded launch agent runsmlx_audio.server(under any label, so another installer's job between restarts counts). Install starts nothing. When that job is sessionvoice's own, runears services uninstall --only speech, then install again. - Install. Nothing on
:8890and no such job. On an Apple-silicon Mac (macOS 14 or later), install builds sessionvoice's own venv,~/.local/share/ears/speech/venv, from the shippedspeech/requirements.lock(mlx-audio==0.5.8), and loads it as the launch agentdev.sessionkit.voice.mlx-audio.EARS_SPEECH_PYnames a python that already has it, and no venv is built. On any other host it installs nothing: it refuses and says what to run when the ears need:8890, and warns when only the mouth does.
--dry-runsays which of these install would take, and builds and writes nothing.ears services statushas aspeechrow that names who serves:8890.ears services uninstall --only speechremoves onlydev.sessionkit.voice.mlx-audio, never a server someone else installed, and keeps its venv. While sessionvoice's own server runs, a re-install just uses it, so to upgrade it, uninstall it and install again: install re-applies the lock's pins to the venv it finds. The lock carries a hash for every pin, and install refuses an artifact that does not match it. Two pins,docoptandwebrtcvad, ship only as source, so uv builds them, and the setuptools it builds them with is hash-checked too, from the shippedspeech/build-constraints.txt. A new venv is checked in full. A re-apply checks only what it installs, not what is already in the venv, so to check an existing venv, remove it and install again. The server fetches no model at install. The first transcription downloads Parakeet, and the first line spoken downloads Kokoro and its voices, so each waits on a large download once.These weights are not pinned. mlx-audio's server takes whatever is on Hugging Face
mainat first use, and it has no way to ask for a revision, so a later first use (a new Mac, a cleared cache) can load different weights.ears services statushas threeweightrows, formlx-community/parakeet-tdt-0.6b-v3,mlx-community/Kokoro-82M-bf16andprince-canuma/Kokoro-82M(the voices). Each says whether the commit the Hugging Face cache'smainpoints at is the one sessionvoice expects (SPEECH_WEIGHTSinsrc/ears/services.py):match,DIFFERS, ornot cached. It reads the cache's files and never fetches. It only reports drift; it does not stop or change what the server loads. A real pin, fixed at install, is not built yet.sessionvoice can promise "never two servers" only from its own side: VoiceMode's installer does not ask, so
voicemode service install mlx-audiorun after sessionvoice's install loads a second mlx-audio job, and one of the two crash-loops.Without a speech server, point the ears at another OpenAI-compatible STT (
EARS_LISTEN_STT_URL, orurlin~/.sessionkit/voice/settings/ears.json;src/ears/listen/stt.py:105) and the mouth at another TTS (MOUTH_KOKORO_URL, orttsin~/.sessionkit/voice/settings/mouth.json), a hosted one included, withMOUTH_TTS_KEYor the key file~/.sessionkit/voice/settings/tts.keyfor a keyed one: see A hosted speech endpoint. On a Mac the mouth'sautofalls back tosay(src/mouth/backends.py:200). - Opt-out.
-
ffmpeg on
PATH:mouth play(src/mouth/backends.py:261), the mouth's.mp3/.m4aoutput (src/mouth/audio.py:137),ears file(src/ears/cli.py:92) andears eval(src/ears/eval.py:169). -
SwitchAudioSource on
PATH(Homebrewswitchaudio-osx): the keeper sets the default output with it, and reads with it only as a fallback (keeper/ears-keeper.sh):check_outputreads the default output and the output list from input-watch's JSON file (docs/audio-devices.md) and asks SwitchAudioSource only when that file is missing or stale;default_inputreads the legacyinput-watch.statewhile the watcher is running and asks SwitchAudioSource only when it is not.keeper/speak:13-17still reads with it. -
sessionmail's
messageonly if you use mail: the mouth's notices and the keeper's turn mail (keeper/ears-mail.py) go out bymessage send, never straight to an MTA. It delivers through postfix, or, on a Mac with no postfix, through sessionmail's mail drop (SESSIONMAIL_SENDMAIL=drop). Mail tomouth@<host>reaches the speech queue only if that carrier delivers it there (src/mouth/box.py). The mouth's notices go throughMOUTH_SENDMAIL(src/mouth/notice.py): only the mouth service (mouth serve,mouth mail,mouth retract,mouth again) uses the defaultmessage; any other process must name its own. ears-mail's command isEARS_MAIL_MESSAGE, elsemessageon PATH, else where uv installs it (~/.local/bin, then uv's tool dir;src/ears/message_bin.py). -
Models.
ears installfetches only Silero VAD, to~/.cache/ears/silero_vad.onnx, pinned to one upstream commit and checked against its SHA-256 (install()insrc/ears/cli.py; the pin is insrc/ears/vad.py, anddocs/models.mdnames it). It does not fetch the Smart Turn model that--turns smartneeds (src/ears/smartturn.py:48-72). With mlx-audio installed, themlxbackend loads~/.cache/ears/smart-turn-v3-mlxif it is there, and otherwisemlx-community/smart-turn-v3from Hugging Face on first use. Without mlx-audio, theonnxbackend needssmart-turn-v3.2-gpu.onnxat~/.cache/ears/(or the path inEARS_SMART_TURN_ONNX). Download it yourself from pipecat-ai/smart-turn-v3.
Components
The engine above is the core. Around it, these parts make it an always-on pair of ears on a Mac (depth in docs/architecture.md):
- 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):<home>/logs/conversations/heard_YYYY-MM-DD.jsonl, where<home>is~/.voicemodeon a host that already keeps it there, else~/.sessionvoice(docs/logs.md). On a fresh host that also runs VoiceMode'sconverseas a heard guest, setSESSIONVOICE_HOME=~/.voicemode(orVOICEMODE_BASE_DIR), so that both write one log.- A speaker change ends the turn (
--speaker-split K): 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,bob, ...). - Every turn the side-car named carries
speaker: 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 straybob, 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, launchddev.sessionkit.voice.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.sessionkit.voice.ears-sounds): a short sound in the user's ears when the ears log an event, so they know they were heard. - Turn mail (
keeper/ears-mail.py, launchddev.sessionkit.voice.ears-mail): mails each of the user's finished turns 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), 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, or any turn the side-car named) is mailed as<label>@<host>:alicebecomesalice@<host>, 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 under a person's name).Ears-Echosays a mouth line heard back through the mic was cut out of the turn. On the speakers the mic hears the mouth, so a line's words are cut wherever they sit in the user's turn: at its start (up to3 + 5 words/s × the seconds the line began after the turnof the user's words ahead of it), in the middle, or at the end. The aim is to cut only the echo, never the user's 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 the user's does not drag their 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: carol/2d492fb06d3b 31 words cut, carol/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 the user's 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 the user's 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 the user's first words (first); then it wakes.bin/ears-turnthen reads the whole turn, one line. To arm the waiter for a seat, runears-arm <seat> [talk|alone|room];ears-count <seat>counts the waiters already up. Both are the ears skill's tools, on PATH through the plugin'sbin/; the old path,skills/ears/scripts/ears-arm, still works.ears-armrunsheard-firstfrom PATH. Claude Code puts the plugin'sbin/on its Bash tool's PATH while the plugin is enabled. Outside Claude Code, put this repo'sbin/on PATH first. - 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,dev.sessionkit.voice.ears-keeper(ears services install), holds the room's ear and every call's. - Speaker names (
speakers/, opt-in:ears services install --only speakers): a side-car tags each heard line with who spoke, inspeakers_YYYY-MM-DD.jsonlbeside the heard log, and the waiters read it. See speakers/README.md.
Configuration
Three JSON settings files, each reread with no restart:
- The ears:
~/.sessionkit/voice/settings/ears.json(or$EARS_CONF): listening on or off, the input, the speech-to-text engine, and recording. - The sounds:
~/.sessionkit/voice/settings/ears-sounds.json: which events make a sound, and where it plays. - The mouth:
~/.sessionkit/voice/settings/mouth.json(or$MOUTH_SETTINGS): the output, the voice, the levels and barge-in. For a line's voice and levels, the mouth also reads the agent, seat and session layers; the ears read only their own file.
Environment variables win over single keys (EARS_RECORD over record,
MOUTH_DEVICE over output). SESSIONVOICE_HOME moves the home that holds
the heard log and each part's state; by default it is ~/.sessionvoice, or
~/.voicemode on a host that already keeps its heard log there. The launch
agents are in ~/Library/LaunchAgents/.
Which files and which variables each part reads, and where each one lives: docs/configuration.md. Every key, its type and its default: docs/settings.md.
Logs
- The heard log:
<home>/logs/conversations/heard_YYYY-MM-DD.jsonl, one JSON line for each thing the ears heard and the mouth said, where<home>is the home above. A new file starts each day. - The services' logs:
~/Library/Logs/ears/(EARS_LOG_DIRmoves it).ears-keeper.logis what the keeper did (devices, restarts, calls, a deaf mic);ears-keeper-capture.logis the room capture's own output;ears-mail.logis each turn mail sent or skipped;ears-mouth.logis the mouth's player.
Follow one with tail -F, for example
tail -F ~/Library/Logs/ears/ears-keeper.log, or today's heard log with
tail -F <home>/logs/conversations/heard_$(date +%F).jsonl.
Every log file and what each line means:
docs/logs.md.
- Health:
ears services statusprints one row per launch agent (running, pid N,loaded, not runningornot installed), the one that serves speech on127.0.0.1:8890, and the home in use.
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; a checkout) + 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()
Talk: the live view
talk serve is the live voice conversation in a browser: the user's partials and
turns, every mouth line, and who has heard what, read from the heard log.
talk serve # http://127.0.0.1:8780/ (127.0.0.1 by default)
talk serve --port 8781
It binds 127.0.0.1 unless you pass --host, and it answers only a Host it
knows: 127.0.0.1:<port>, localhost:<port> and the --host address. Any
other Host gets a 403, so a DNS-rebinding page cannot read the conversation.
To see it on your other devices, tailscale serve in front of it is one
option (a note, not a default). It then needs the
tailnet name allowed: talk serve --allow-host machine.example.ts.net, or
TALK_ALLOW_HOSTS. Behind a proxy, talk serve --unix serves a unix socket
and binds no TCP port at all, so no other user can take its port.
Each browser pairs once: every route but the page and the manifest
needs a pairing cookie, so another user on the same machine cannot read the
conversation either. On the host, run talk pair in a terminal and open the
link it prints; talk token --rotate makes every browser pair again. Open talk
at http://127.0.0.1:<port>/, never at localhost. The code is src/talk/.
Install
The distribution is sessionvoice (sessionvoice[nemotron], sessionvoice[all]);
it installs the ears, ears-serve, mouth and talk commands. A bare
pip install sessionvoice carries what mouth say needs. The extras are
capture (the mic and call ears), silero (the Silero VAD), smart-turn
(the Smart Turn gate's ONNX backend), nemotron (mlx-audio) and all. An
import that needs one fails naming it, as pip install 'sessionvoice[EXTRA]'.
uv venv && uv pip install -e '.[all]' # onnxruntime + mlx-audio
ears install
ears services install --agent NAME # the launch agents: mouth, keeper, mail
The proof of a working install: you speak, and the agent WAKES and answers
Not "a heard line appears". The keeper alone writes the heard line, and
it pages nobody: the agent is woken by the turn MAIL (mail,
keeper/ears-mail.py), which needs sessionmail's message and a carrier
(postfix, or the mail drop), the heard box and a wake seat.
An install that stops at "the user speaks, a heard line appears" can leave the
agent deaf to its person, and one that retires room mode on that proof goes
deaf for good. So the proof is: say "hey NAME", and NAME wakes and
answers.
ears services install (--dry-run lists the same checks and writes nothing):
- first finds the mlx-audio speech server on
:8890, or installs its own (dev.sessionkit.voice.mlx-audio), as "A speech server" under Requirements says, and prints which step it took. When it loads its own, the newfinalbelow is written without the probe: that server has not started yet; - installs
dev.sessionkit.voice.ears-mailbeside the mouth and the keeper, unless a hand-made mailer is already on the host: then ours is skipped and said so, because two mailers would mail every turn twice, and themessagecheck reads that plist's PATH, not ours. The hand template renders to the same label and path as install's own job, so install tells them apart by the plist's content: every plist install writes carriesSESSIONKIT_INSTALLED_BYin itsEnvironmentVariables, and a hand render does not. Install never overwrites a hand render;ears services statusnames it. Rendering a template over the plist install wrote replaces install's job. The same rule holds for the keeper and the speakers side-car, and for a hand one under the template's old name (dev.sessionvoice.ears-mail); - replaces a legacy render:
dev.voicemode.ears-mailfor mail, anddev.voicemode.mouth-mailorcom.failmode.mouth-mailfor the mouth (two players would drain one queue). Each is booted out and its plist moved aside to<label>.plist.replaced-<stamp>, never deleted, and ours loads once it has gone. One that will not go stops that service with FAILED; - moves each service it installs off every old label,
com.sessionvoice.ears-<name>andcom.sessionvoice.mlx-audio(the previous ones) andcom.voicemode.ears-<name>(older): each old job is booted out once and its plist removed, and the newdev.sessionkit.voice.ears-<name>is loaded only after the old has gone. An old job that will not go stops that service with FAILED and its label, and the new one is not loaded beside it. A pull alone moves nothing: the old jobs run the new code under the old name until you run install. A second install finds nothing old and does nothing to it.ears services statusnames an old label still on the host, andears services uninstallremoves them all; - checks
messageand its carrier, postfix or the mail drop (it configures neither), and says when the launch agents will not see the carrier your shell sets. It makes theheardbox NESTED under the store dir that carries.tm-store, as~/.mail/agents/<store>/heard/, when there is exactly one such dir. It then runsvmailbox-genif that is onPATH, and otherwise prints the commands, naming the host's copy (~/.mail/.postfix/vmailbox-gen: sessionmail keeps it offPATH). It also prints the box to subscribe to:message subscription add heard+macbook; --agent NAMEadds NAME's wake seat (ears wake add NAME) and sends the keeper's notices to NAME (EARS_NOTIFY;--notify ADDRsends them elsewhere). Without it, nobody is woken, and install says so. The notices then go to the operator whensession operatornames one (read at the keeper's start; install names the address), else they are log-only. To mail nobody on a host with an operator, setEARS_NOTIFY=""(empty) in the keeper's environment. See "Operator and privileged seats" below;- refuses, before any plist is written, when the settings schemas are
missing: a checkout's
settings/, else the wheel'sears/_settings/. It then copies each beside its settings file as a dot file (.ears.schema.json,.ears-sounds.schema.json,.mouth.schema.json); - refuses, before any plist is written, when the Silero VAD model is missing,
unless VoiceMode's copy (
~/.voicemode/models/silero/) is there to link. Runears installfirst. A keeper that finds no model says so once and holds, and does not restart its capture every few seconds; - probes a new
finalengine with one short clip before writing it toears.json, and falls back to whisper:2022(and says so) when it does not answer. Each probe is said first, the dry run's too:probing <url> (read-only). Parakeet on the:8890server needs an mlx-audio with its STT models: this project's own extra pinsmlx-audio>=0.5.4. An mlx-audio that answers every Parakeet request 500 (No module named mlx_audio.tts.models.parakeet_tdt_0) is not one, and the probe catches it. That the cause is an older mlx-audio is an inference, not measured.
The ride-along hook is a step of its own. Installing the Claude Code
plugin does not turn it on: the plugin ships no hooks, so no agent sees what
the mic heard until you opt its seat in. To opt one seat in, add the heard hook to
.claude/settings.local.json in the directory that agent is launched from
(that seat only; ~/.claude/settings.json would be every agent):
{
"hooks": {
"PostToolUse": [{"hooks": [{"type": "command", "command": "ears heard hook || exit 1", "timeout": 5}]}],
"PostToolBatch": [{"hooks": [{"type": "command", "command": "ears heard hook || exit 1", "timeout": 5}]}]
}
}
If the file already exists, merge these two entries into its hooks rather
than replacing it. Keep || exit 1: it turns any failure into exit 1, which
shows a hook error and lets the agent carry on; an exit 2 (an argument error
gives one) would stop the agent's turn. ears must be on the PATH that claude was started with,
not just the Bash tool's: start claude from a shell with the venv active,
or link .venv/bin/ears into a directory on your PATH. It takes effect on
the agent's next tool call. VOICEMODE_HEARD_HOOK=off silences it without
editing the file.
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.
sounddevice is a base dependency, so it is installed, but
tests/audio_fence.py refuses the real module in every test; the
tests/test_ears_sounds_call.py tests run against a fake one. One silero
test skips without onnxruntime: uv sync --extra silero brings it. The
music-ducking tests need no mpv: they run against a fake mpv socket in a
temp dir (tests/mouth/test_mouth_mpv.py).
Review rigs and scripts outside pytest get none of the suite's fences.
A process that imports the mouth is refused the real message, and its drop
notices are recorded failed: message send fenced. But a rig that starts a
player (mouth say, mouth serve, mouth mail, or mouth.say() with its
spawn), or runs mouth retract or mouth again, runs the real service, and that does mail. Set
MOUTH_SENDMAIL to a stub (python3 tests/fixtures/refuse_sendmail.py,
by its absolute path) or MOUTH_NOTICE=off, and HOME to a temp
dir, before anything starts. A temp HOME alone is not a fence: a reply notice
needs no box. A rig that runs keeper/ears-mail.py without --dry-run sets
EARS_MAIL_MESSAGE to the same stub.
Measured (one Mac, 2030-06-09; our own eval set and scoring script, 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 (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. When a line chops:
docs/troubleshooting.md.
mouth say --voice carol --device default "Hello."
mouth say --voice carol --device call --hold turn-end "On the call."
mouth --help # stop, retract, amend, status, devices, serve
Moved here from VoiceMode feat/mouth-say @ 64cccd80, so the mouth lives
with the ears. The player is this repo's
mouth mail, which ears services install runs as a launch agent. The
mouth imports nothing from VoiceMode.
Music ducking ($MOUTH_DUCK) sets the DJ's mpv volume over mpv's
JSON IPC socket (src/mouth/mpv.py). The socket is /tmp/voicemode-mpv.sock
unless $MOUTH_MPV_SOCKET names another. With no mpv on that
socket, nothing is ducked and the line still plays.
The heard log came over with the capture (ears.heard, ba37091), and clone
voices resolve through the mouth's own copy of VoiceMode's resolver
(src/mouth/resolver.py).
Tests: make test ARGS=tests/mouth (see Tests above).
Operator and privileged seats
Who the operator is, and which seats are privileged, is one setting on the
host, never a name in this code. It is sessiontools' operator block in
~/.sessionkit/tools/settings/session.json. sessiontools owns it, and
this repo never parses that file: it runs session operator and reads the one
line of JSON it prints, through one reader, ears.operator_config.
$ session operator
{"operator": "you@host.example", "mailbox": "you", "privileged": ["helper", "you", "you@host.example"]}
"Configured" means privileged is not empty. AGENT_OPERATOR_ADDR on its own
moves operator only, so it configures nothing and grants nothing.
What reads it:
- mouth
allowed(), who may make the mouth speak. Unconfigured, it is a From on this host or in$MOUTH_MAIL_ALLOW, as before. Configured, it is a From on this host or inprivileged. Then$MOUTH_MAIL_ALLOWonly narrows: when it is set, a sender must be in it as well. An env var never adds a sender. A local seat outside ALLOW is refused too (mouth saywritesFrom: <agent>@<host>), so an operator who narrows puts@<host>in ALLOW to keep this host's seats. - Matching. An entry with an
@matches that address exactly. A bare entry (helper) is a mailbox on this host only (helper@<host>), neverhelper@<another host>. To admit a seat on another host, write its full address. - barge-in (
barge_inin~/.sessionkit/voice/settings/mouth.json). With the key absent and an operator configured, the default is the operator'smailboxin the room (calls stayanyone), read as a speaker name: enroll under your mailbox name with the speaker side-car. Set the key, toanyoneor to names, and it wins. - the keeper's notices (
EARS_NOTIFY). Unset, they go to the operator's address when one is configured, read once at the keeper's start. Set but empty (EARS_NOTIFY="") means mail nobody. Set to an address, they go there.
The fallback. If nothing is configured, or there is no session CLI, or
it fails, times out or prints something that is not a JSON object, everything
behaves as before this setting existed, $MOUTH_MAIL_ALLOW included.
MOUTH_SESSION_BIN set to any value ("", or a path set for the
voice cascade) switches the reader off: a path in it is never honoured for
privilege.
KNOWN LIMIT (accepted): on a CONFIGURED host whose reader is off
(MOUTH_SESSION_BIN set, or session not on the mouth's PATH), the mouth
falls back and ALLOW still ADDS, as it did before this setting existed. Only
the mouth's own service env reaches this; a sender cannot. The mouth logs a
WARNING when ALLOW rides the fallback.
The WARNING fires when the operator reader did not answer (it is off, the
session CLI is missing, or its output is bad) and ALLOW is set. The mouth
cannot tell whether the host has an operator block then, so the warning never
claims one. It is said once at the mouth's start (mouth: the operator reader is ..., so $MOUTH_MAIL_ALLOW still ADDS senders ...), and once per
message that ALLOW alone admits, as an allow-fallback row in the player's
log naming the sender and the reason (off, absent or failed).
Metadata
Release files for sessionvoice 0.2.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 | Provenance | |
|---|---|---|---|---|
| sessionvoice-0.2.0.tar.gz | 1.1 MB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sessionvoice-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.8 MB
Release files / sessionvoice-0.2.0.tar.gz
| Download URL | sessionvoice-0.2.0.tar.gz |
|---|---|
| Size | 1.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2dae984864a8a6016be536baf8b89349ae1f24dbc8640a08cf9be0832818e91b
|
|
BLAKE2b-256 checksum How to use checksums |
61d81c5f9722b8cfbd277208c28b323fa165cfe5f3350a7c7f13976b0e000f40
|
| 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.2.0-py3-none-any.whl
| Download URL | sessionvoice-0.2.0-py3-none-any.whl |
|---|---|
| Size | 660.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
43e597532686b93dfc857de906a2d7480c82a6e189cdd890a087f27172063b5e
|
|
BLAKE2b-256 checksum How to use checksums |
3b26183f2b9e9e0fb92104d5e47fd841981b5a624dd5277122169d80323b0958
|
| 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}
|
Security for sessionvoice 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.