Instructions to use abhinavpgagi/personaplex-tool-calling with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Moshi
How to use abhinavpgagi/personaplex-tool-calling with Moshi:
# pip install moshi # Run the interactive web server python -m moshi.server --hf-repo "abhinavpgagi/personaplex-tool-calling" # Then open https://localhost:8998 in your browser
# pip install moshi import torch from moshi.models import loaders # Load checkpoint info from HuggingFace checkpoint = loaders.CheckpointInfo.from_hf_repo("abhinavpgagi/personaplex-tool-calling") # Load the Mimi audio codec mimi = checkpoint.get_mimi(device="cuda") mimi.set_num_codebooks(8) # Encode audio (24kHz, mono) wav = torch.randn(1, 1, 24000 * 10) # [batch, channels, samples] with torch.no_grad(): codes = mimi.encode(wav.cuda()) decoded = mimi.decode(codes) - Notebooks
- Google Colab
- Kaggle
Tool calling for full-duplex speech-to-speech (PersonaPlex / Moshi)
Function calling on a full-duplex speech-to-speech model, without breaking the duplex property.
The model keeps listening and speaking while an external API call runs, and the result is spoken in the model's own voice and prosody β not spliced in as TTS, and without pausing the conversation.
Built on NVIDIA PersonaPlex (7B, itself built on Kyutai Moshi).
Why this is not obvious
A speech-to-speech model like Moshi has no text interface to hook a tool into. It emits, every 80 ms frame:
- audio tokens (what you hear), and
- one inner-monologue text token β the model's own running transcript of what it is about to say
There is no "assistant message" to append a tool result to, and no turn boundary to pause at. The two obvious approaches both break it:
| approach | what breaks |
|---|---|
| Pause generation, call the API, resume | The model is full-duplex β it is always generating. Pausing produces dead air and desynchronises its sense of time. |
| Call the API, speak the result with TTS | Voice and prosody change mid-conversation. It stops sounding like one speaker. |
What this does instead
Teacher-force the tool result into the inner monologue, one token per frame. The model then says it, in its own voice, as if it had thought of it:
user audio ββ(transcription)βββ
βββ> running transcript ββ> LLM + tool schemas
model's own \x02 text βββββββββ β
HTTP call (allowlisted)
β
βββ inner monologue <ββ one forced token per 80 ms frame <ββ
β
the model speaks the answer itself β same voice, same prosody
Concretely, in forked_server.py's generation loop:
if pending_text_tokens: # a tool result is waiting
forced_text = pending_text_tokens.popleft() # override the model's own sample
elif _nudge["force"] > 0: # else a turn-taking nudge
forced_text = _NUDGE_TOKEN
else:
forced_text = None # else the model samples freely
tokens = lm_gen.step(codes[:, :, c:c+1], text_token=forced_text)
The result is drip-fed one token per frame rather than injected as a block, so the model's audio head generates speech for it at the natural rate. Generation never stops; the duplex property is preserved throughout.
A filler phrase ("let me check that") can be injected the same way the moment a
call starts, so the pause while the API responds sounds deliberate.
Using it
The API list is supplied per session, base64-encoded in the X-Functions
header on the WebSocket upgrade β so different callers get different tools without
redeploying:
{
"prompt": "Decide whether to call a function based on the live conversation.",
"filler": "Sure, let me check that for you, one moment.",
"allowed_hosts": ["api.open-meteo.com"], // SSRF allowlist β required
"functions": [{
"name": "get_weather",
"description": "Current weather for a location given latitude and longitude.",
"endpoint": "https://api.open-meteo.com/v1/forecast",
"method": "GET",
"param_location": "query",
"static_params": { "current_weather": true },
"parameters": { // OpenAI tool schema
"type": "object",
"properties": {
"latitude": { "type": "number" },
"longitude": { "type": "number" }
},
"required": ["latitude", "longitude"]
}
}]
}
python client/personaplex_client.py \
--url wss://<your-host>/api/chat \
--api-key "$API_KEY" \
--script my_persona.txt \
--functions examples/weather.json \
--input mic
Then ask it about the weather somewhere. It will say the filler, call the API, and speak the answer without ever stopping.
Layout
| path | what |
|---|---|
src/reasoner.py |
transcript accumulation, LLM tool decision, result β injection |
src/http_executor.py |
declarative function spec β HTTP call; SSRF allowlist |
src/session_config.py |
per-session X-Functions config, validated |
src/forked_server.py |
PersonaPlex server + the injection bridge (see licence below) |
client/personaplex_client.py |
mic client; stereo recording; per-turn latency |
bench/ |
turn-taking latency harness β see below |
examples/weather.json |
a working end-to-end example |
Turn-taking latency harness
bench/voicebench.py measures the metric that matters for a voice agent:
latency = model_speech_start β caller_speech_end
Unlike VAD-based harnesses it synthesises the caller's turns, so speech-end is an exact sample rather than a VAD guess with unknown hangover bias. Onset is the first of three consecutive 20 ms windows above an RMS threshold, timestamped at the first window so sustain confirmation adds no upward bias. Validated against a mock server with a known injected delay.
It also reports what latency alone hides: dropped turns (the model stayed silent) and barge-ins (it talked over the caller) β both excluded from the latency figure rather than silently flattering it.
python bench/make_turns.py # synthesise caller turns (macOS `say`)
python bench/voicebench.py --url "$WS_URL" --runs 5
Requirements and caveats
- Gated weights.
nvidia/personaplex-7b-v1requires accepting the NVIDIA Open Model Licence. Weights are not included here. - An LLM API key. The reasoner uses OpenAI for transcription and the tool
decision (
LLM_API_KEY). This is not self-contained speech-to-speech β the tool-decision loop is text and external. - One conversation per process. The server holds a single
asyncio.Lockaround oneLMGen. Multi-worker concurrency on a shared GPU was tested and collapsed β scale by process-per-GPU or by instance, not by workers per GPU. - SSRF. The server makes HTTP requests to client-supplied URLs.
allowed_hostsis enforced and private/loopback/link-local IPs are blocked β keep it set. - No authentication upstream. PersonaPlex ships with none.
S2S_API_KEYaddsAuthorization: Api-Key <key>checked before the WebSocket upgrade. Unset means the endpoint is open; the server logs a warning at startup. - VRAM. ~19 GB in use on a 24 GB A10G. It has OOM'd under load; 24 GB is the practical floor and there is little headroom for long sessions.
Licence and provenance
src/reasoner.py,src/http_executor.py,src/session_config.py,client/,bench/β original work, Β© 2026 Abhinav Kalvacherla, Apache-2.0 (seeLICENSE). Each file carries an SPDX header.src/forked_server.pyβ a fork of PersonaPlex'smoshi.server, Β© NVIDIA CORPORATION and Β© Kyutai, MIT. The original notice is retained in the file. Modifications are listed inNOTICE.- Model weights β NVIDIA Open Model Licence, not distributed here.
- Downloads last month
- -