Running a Custom Avatar on NVIDIA Tokkio: Runtime Integration, Japanese Speech, and Live Operations

1. Define the runtime deliverable

Our definition of done was a Japanese conversation with the avatar in a browser, with lip sync. Every startup check had to pass, the renderer had to survive a soak of at least 12 hours without a restart, and no silence inside a turn could last longer than five seconds.

Running is not ready: we saw the stage dead while every pod reported Running. On 2026-09-17 the startup check had 48 items and passed all of them on the revision we took to the event. Gaps remain: the exact renderer binary we shipped has no continuous 12-hour run on file, and no turn was ever timed end to end against the five-second limit (section 11).

2. Pin the stack

As of September 2026, 5.0 GA is the latest published Tokkio release and its documentation has moved to the archive site, so pin everything.

ComponentWhat we ran
Tokkio app charttokkio-1stream-with-ui 5.0.0-GA, one-click install, single node, bare metal
ACE Controller5.0.0, built on pipecat 0.0.68 [2]
Audio2Face-3D (A2F-3D)1.3.16 [3]
Riva2.19.0, with the ja-JP streaming Conformer swapped in (section 7)
Unreal Renderer microservice0.1.3
Unreal Engine build5.5.4, Linux, Development configuration
Video Storage Toolkit (VST, also called VMS)1.3.0
Animation Graph1.1.0
Kuberneteskubeadm 1.31.2, containerd 1.7.23 with the systemd cgroup driver on cgroup v2
GPU Operatorv24.9.2, NVIDIA Container Toolkit 1.17.4, Container Device Interface (CDI) off
HostUbuntu 22.04 LTS, kernel 5.15, 2x NVIDIA RTX 6000 Ada Generation

3. Keep the ACE Controller, replace what sits behind it

Our provider gateway and TTS service listen on the host’s loopback, which the controller pod cannot reach. A reverse proxy on the host forwards to them, admits only the pod network and the host itself, and turns buffering off for streaming. That is segmentation, not authentication. The gateway calls a cloud LLM API first, with local open-weight models served by Ollama (a 32B primary and a 7B standby) as the fallback. It emits one newline-delimited JSON (NDJSON) line per sentence, so TTS starts on the first sentence while the model is still writing. Our TTS service calls a cloud streaming TTS service.

The controller volume sits outside git and review, so the startup check hashes each injected module against the release and checks that bot.py constructs each class; its first run found two drifted modules.

4. Accept the packaged renderer as a runtime contract

The author of Part 1 also built the renderer package, including the stage actor, the facial mapping and the patches to NVIDIA’s Unreal plugins. The renderer author verified HTTP stream registration and removal and the face-animation receive path on an earlier Windows package with a mock Animation Graph. The earlier Linux package was verified up to the build and transfer integrity, and a later Linux build was started and rendered on a Linux GPU. This article covers the rest of the acceptance: running inside Tokkio on our server, real Audio2Face-3D output and dialogue, browser video and audio over WebRTC, and long runs.

Each frame, the stage actor reads the interpolated ACE weights and maps the 52 ACE inputs to 62 animation curves. A dedicated AnimInstance outputs the curves through the animation proxy, and the character’s Reallusion Rig Logic post-process Animation Blueprint turns them into morph targets and jaw and eye bone poses.

ACE inputOutputs in Part 1Outputs in the stage build
MouthFunnel14, one per quadrant
MouthPucker14, one per quadrant
MouthRollLower12, left and right
MouthRollUpper12, left and right

That adds eight outputs, so 54 becomes 62. Two inputs are approximations: MouthClose drives a lip-tightening shape, so closed lips are not guaranteed, and MouthShrugUpper maps to an approximate control. MouthShrugLower uses the character’s existing bone-driven pose. This mapping comes from an earlier renderer build. Later builds, the stage build among them, moved the same mapping into a new actor class. At startup the stage build’s log shows all 62 bindings resolved, which does not mean that every expression or real speech looks right.

The per-frame step below is an excerpt from that earlier renderer build, with logging and verification code omitted.

CC5PackagingStageActor.cpp, earlier renderer build (excerpt; logging and verification code omitted)
void ACC5PackagingStageActor::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);
    ApplyACECurves();
}

void ACC5PackagingStageActor::ApplyACECurves()
{
    if (!ACECurveSource || !CharacterMesh)
        return;
    CachedACECurveWeights.Reset();
    ACECurveSource->GetCurveOutputsInterp(CachedACECurveWeights);
    if (CachedACECurveWeights.Num() < RequiredACECurveCount)
    {
        if (bHadActiveACECurves)
            ResetACEMorphTargets();
        return;
    }
    auto* FaceAnimation =
        Cast<UCC5ACEAnimInstance>(CharacterMesh->GetAnimInstance());
    if (!FaceAnimation)
        return;
    for (const FACEToCC5MorphBinding& Binding : ACEToCC5MorphBindings)
    {
        const float Weight = FMath::Clamp(
            CachedACECurveWeights[Binding.CurveIndex], 0.0f, 1.0f);
        FaceAnimation->SetFaceCurve(Binding.MorphTarget, Weight);
    }
    bHadActiveACECurves = true;
}

A build can start cleanly and register with the signalling server, then still fail at the first subscription or after roughly a day of uptime.

StepWhat we checkWhat failure looks like
Startup log checksLog lines for the validated mapping, the actor registered with its audio curve source and the frame-rate cap, and the setting r.GPUStatsEnabled=0 in effect. Then port 8021 [9] is listening and answers GET /health from inside the cluster.A line is missing, or port 8021 does not answer while the pod stays Running.
Soak12 hours or more, sampled every 30 minutes: container restarts, the GPU memory trend, and name-table (FName) growth while idle.Sustained FName growth is a failure signal to investigate. The earlier long-run crash has not been reproduced and attributed to this cause.

For FName growth, the renderer author traced a per-frame name to Unreal Engine 5.5’s realtime GPU profiler. The growth reproduced while idle and stopped in a short test with r.GPUStatsEnabled=0. That does not show it caused the earlier crash, so we also recreate the renderer pod before each show day.

5. Two pipecat details that decide what the avatar remembers

Two behaviors of pipecat 0.0.68 decide what the model later sees as its own answers.

We reproduced this in the controller pod on 2026-09-04 with a reduced pipeline: three uninterrupted turns, one run per variant. All three answers stayed uncommitted. Forwarding the frame once, after synthesis, committed all three. From the pipecat code, the full pipeline’s aggregator also commits when the bot stops speaking, including between sentences, so without the fix commits follow speaking gaps rather than the ends of answers. We did not isolate this on the live stack.

custom_tts.py (excerpt)
from pipecat.frames.frames import LLMFullResponseEndFrame, TTSSpeakFrame
from pipecat.services.tts_service import TTSService  # pipecat 0.0.68


class CustomTTSService(TTSService):
    async def process_frame(self, frame, direction):
        # Filler lines ride TTSSpeakFrame and were never counted as queued
        # model sentences, so their synthesis must not dequeue one.
        if isinstance(frame, TTSSpeakFrame):
            self._skip_dequeue = True
            try:
                await super().process_frame(frame, direction)
            finally:
                self._skip_dequeue = False
            return
        await super().process_frame(frame, direction)
        # With push_text_frames=False, 0.0.68 flushes the last sentence on
        # this frame but does not forward it. The assistant context
        # aggregator commits the answer on it, so re-emit it once, after
        # the base class has synthesized, so audio precedes the commit.
        # pipecat >= 0.0.108 emits it in the base class: drop this branch there.
        if isinstance(frame, LLMFullResponseEndFrame):
            await self.push_frame(frame, direction)

Note. From pipecat 0.0.108 (pull request #4145), the base class emits the end frame itself [11]. On those versions a turn with no audio still loses the frame, an issue still open on 2026-09-28 [12]; with this override on 0.0.68, the frame is forwarded whether or not the turn produced audio.

The second behavior is how a barge-in is filed. On an interruption, pipecat commits the sentences whose text frame already reached the aggregator and drops the rest, which we reproduced in the pod on 2026-09-04, in one run. So our TTS service yields each sentence’s text frame after its audio has fully streamed out, and a sentence cut mid-audio is not filed as said. Precision is one sentence, because the service returns audio without word timestamps. The next request’s history marks a cut answer with a prefix meaning “answer cut off by the listener”.

6. Design against dead air

Five seconds of silence inside a turn was our hard limit, the dead-air limit. Every design choice was checked against it (Figure 2).

The two measured series in Figure 2 start at different points and do not add up. The first-sentence series can also read low, because a barge-in before the first sentence stops its clock. We never measured microphone to speaker.

A filler watchdog in our LLM service speaks the first filler line time_delay seconds after the last text sent to TTS; we ran it at 1.2 s. It then speaks one more for every further four seconds of silence, at most four per turn, and stops at the first model sentence.

A filler line plays before the model has said anything, so it must not assert anything. In testing,「ええ、ええ」(“yeah, yeah”) after a yes-or-no question was a false “yes”. We deleted six agreeing lines and keyed the filler pools by the listener’s intent, which deterministic suffix rules classify without a model call. Questions never hear an acknowledgement, statements never hear “good question”, and unknown intent gets neutral interjections. A misclassification can add an acknowledgement or a “good question”, never an answer; we have not measured how often one happens. Keep filler: [] rather than deleting it, or the stock English fillers come back.

LLM failover is allowed only inside the switch window, from turn start until the model’s own first sentence leaves the gateway. Filler lines do not close it. After it closes, the provider is sticky for the turn. An LLM stream over server-sent events (SSE) succeeds only with [DONE] or a finish_reason; a stream that just stops, or an empty answer, is a failure. With an invalid cloud key (2026-09-02, n=2 turns), the local 32B model answered with fallback reason auth.

Video 1. One turn against the 5 s dead-air limit, in three illustrative scenes with AI-generated narration: a fast turn, a slow turn in which filler lines cover the wait while the gateway fails over to a fallback model inside the switch window, and a barge-in. Only the labeled values are configured (VAD stop_secs 1.2 s, filler time_delay 1.2 s, then every 4 s, at most 4 per turn); every other duration is illustrative, not measured.

Video 1’s narration is AI-generated with a stock voice of an open-source text-to-speech model; it is not the avatar’s voice, and the system itself does not use that model. In the slow turn, no silence reaches 5 s, but the primary fails fast. If it hung until its timeout instead, the wait would outlast all four filler lines, so the watchdog alone does not bound dead air. In the barge-in, only sentences whose audio had fully streamed out of the TTS service are kept, and the next request marks the answer as cut off by the listener (section 5).

Reasoning models are banned, because their content stays empty while they think. OpenAI’s gpt-5.6-terra passed our admission check in 3 runs on 2026-08-17, each with a prompt first content delta, no reasoning deltas and a clean finish.

Over two days of a live event in September 2026, rehearsals and staff tests included, the gateway’s log recorded 405 conversation requests, all answered with HTTP 200, and no warning or error entry. That does not prove that every turn completed or that no failover happened: an empty answer can fail over without a warning, and the log keeps no per-turn outcome.

7. Make Japanese speech sound right

Our prompts forbid Arabic numerals, so the model writes years like 二〇二六年, and the cloud TTS read them digit by digit. The TTS service rewrites three or four kanji digits before 年 into positional form, 二千二十六年. The spoken sentence lasted 4.29 s in digit form and 3.44 s in positional form (one clip each, 2026-09-04).

Other words go through a reading table. An entry needs a person to hear the misreading, and then to hear the kana rewrite read correctly, each on two separate samples, because the cloud TTS is not deterministic. Two samples were not always enough: the rewrite for the character’s own name passed and then misread on the deployed stack.

8. Fix the video path, not the skin

In the browser, the avatar looked darker than the renderer’s frames, most of all in the mid-tones, and the shift looked like a gamma curve, not an offset (Figure 3). We measured this on 2026-09-17, on one client and two builds that share the shipped binary.

The renderer was not the cause: its Linux render matched the Windows render of the same build and the editor viewport. We did not split the shift between VST and the client. Brightening the skin was the wrong fix: doubling the face’s base color multiplier washed the face toward white.

9. When GPU pods silently lose the GPU

Caution. Disabling the timer that causes the reloads is itself a reload, because systemctl disable and enable reload the systemd daemon. Do it only while the Tokkio workloads are scaled to zero.

Nothing breaks at reload time: the kernel checks device permission only at open, so open handles keep working and every pod stays Running (Figure 4). The next open() fails, with a misleading error. VST opens a new hardware encoder per browser session, so the first session after a reload crashed it, repeatedly. Its log blamed /dev/v4l2-nvenc, but on x86 that library loops over /dev/nvidia0 to /dev/nvidia15. We never caught the permission error at the crash instant, so this is our best-supported explanation.

A manual reboot into a new kernel added a race. The kubelet restarted the Tokkio pods before the toolkit DaemonSet had rewritten containerd’s config, so their sandboxes had no /dev/nvidia*. CrashLoop restarts containers, not sandboxes, so wait for the toolkit DaemonSet to report Ready, then delete the pods.

10. Common problems and where to fix them

Part 1 closed with a table of asset symptoms. This is the runtime counterpart.

SymptomWhere to fix
Renderer and video
add_stream returns 400; audio but no videoThe actor from GetActor() lacks UACEAudioCurveSourceComponent. Fix the Unreal build.
Pod Running, every session blank after a renderer crashThe engine can fail to rebind port 8021. Delete the pod, and probe :8021/health from inside the cluster.
GPU and cluster
VST crashes on the first session after host maintenance; its log blames /dev/v4l2-nvencA daemon-reload revoked GPU access. Reload only at zero replicas; otherwise delete all GPU pods (section 9).

11. What we have not measured

We never measured microphone-to-speaker latency, ASR character error rate, what word boosting changed, or how often the filler intent rules misclassify. We have not verified that playback and Audio2Face-3D receive the same PCM on the Tokkio path. The exact renderer binary we shipped has no continuous 12-hour soak on file. For the event, the controller logs that feed our latency report were lost when the stage was scaled to zero.

Where this leaves the series

References

Links checked September 2026. Tokkio and ACE documentation links point to the archive site; the Riva and Container Toolkit links point to current documentation, not to the versions we ran.

[1] DTC Tech Blog. Creating a Custom Avatar for NVIDIA Tokkio with Character Creator 5 and Unreal Engine →
Part 1 of this series: the hand-off package and, in its section 7, 52 ACE inputs mapped to 54 CC5 targets.

[2] NVIDIA. Tokkio 5.0 GA release notes →
Tokkio 5.0 GA uses pipecat 0.0.68.

[3] NVIDIA. Audio2Face-3D Microservice: Release Notes →
Includes the 1.3.16 entry.

[4] NVIDIA. Unreal Renderer Microservice: Custom Avatar, Arbitrary Asset →
ACE plugins written and built for Unreal Engine 5.3; an ACEAudioCurveSource on the avatar actor.

[5] NVIDIA. Tokkio 5.0: Architecture →
The answer path from Audio2Face-3D through the Animation Graph to the renderer.

[6] NVIDIA. ACE Controller Microservice: User Guide →
The controller as pipecat plus the nvidia-pipecat extension.

[7] NVIDIA. Tokkio 5.0: Customization Options →
llm_processor in config.yaml with three stock LLM services; other processors, TTS included, in bot.py.

[8] NVIDIA. Tokkio 5.0: Tokkio UI Troubleshooting →
VST_INBOUND_STREAM_ERROR: the UI cannot connect to the inbound avatar stream; TURN problems are the most common cause.

[9] NVIDIA. Unreal Renderer Microservice: Parameters →
HTTP API port 8021; IAUEMS_WINDOW_WIDTH and IAUEMS_WINDOW_HEIGHT, 1280 and 720 by default; additional startup arguments.

[10] pipecat. tts_service.py at v0.0.68 →
With push_text_frames off, the subclass must push LLMFullResponseEndFrame.

[11] pipecat. Pull request #4145: TTS improvements →
From 0.0.108, the base class emits the end frame for turns with audio.

[12] pipecat. Issue #5654: TTSService with push_text_frames=False drops the LLMFullResponseEndFrame of any turn that produces no audio →
Open as of 2026-09-28.

[13] NVIDIA. Riva ASR Overview →
Language table including Japanese (ja-JP) Conformer models.

[14] NVIDIA. Basics of Speech Recognition and Customization of Riva ASR →
Word boosting at request time.

[15] NVIDIA. NVIDIA Container Toolkit: Troubleshooting →
A daemon-reload under systemd-managed cgroups drops GPU access that the legacy hook granted; explicit device nodes or CDI prevent this.

[16] NVIDIA. gpu-operator issue #503: Kubernetes containers using NVIDIA_VISIBLE_DEVICES lose device access after systemctl daemon-reload →
Containers using NVIDIA_VISIBLE_DEVICES lose GPU access on daemon-reload; pods with resource requests keep it.

One Comment

  1. Pingback:Creating a Custom Avatar for NVIDIA Tokkio with Character Creator 5 and Unreal Engine – DIGITAL TWIN CENTER

Leave a Comment

Your email address will not be published. Required fields are marked *