<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Robotomy Labs Blog</title>
        <link>https://robotomy.ai/blog</link>
        <description>Robotomy Labs Blog</description>
        <lastBuildDate>Thu, 16 Jul 2026 23:26:55 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[You don't have to reflash: the cloud-LLM path around the JetPack 5.1.1 ceiling]]></title>
            <link>https://robotomy.ai/blog/cloud-llm-escape-hatch</link>
            <guid>https://robotomy.ai/blog/cloud-llm-escape-hatch</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[The previous post makes a hard claim it's only true if local inference is actually the goal.]]></description>
            <content:encoded><![CDATA[<p>The <a class="" href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling">previous post</a> makes a hard claim: if you want current local-LLM tooling on the G1 EDU, JetPack 5.1.1's CUDA 11.4 ceiling is structural, and a reflash to JetPack 6.2 is the real fix, not a workaround. That claim needs one honest qualifier attached to it: <strong>it's only true if local inference is actually the goal.</strong></p>
<p>If it isn't — if a cloud LLM is an acceptable part of your architecture — the ceiling doesn't apply to you at all. This post is about that path, because it's a completely legitimate one, not a lesser one, and it's exactly what this project ran on for its first real era.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-ceiling-doesnt-touch-this-path">Why the ceiling doesn't touch this path<a href="https://robotomy.ai/blog/cloud-llm-escape-hatch#why-the-ceiling-doesnt-touch-this-path" class="hash-link" aria-label="Direct link to Why the ceiling doesn't touch this path" title="Direct link to Why the ceiling doesn't touch this path" translate="no">​</a></h2>
<p>The CUDA 11.4 / Python 3.8 ceiling blocks <strong>local, on-device GPU inference</strong> — that's specifically what Ollama's GPU backends and modern local STT models need, and specifically what stock JetPack 5.1.1 can't provide. A cloud-based stack (GPT-4o or an equivalent hosted LLM, Deepgram or equivalent hosted STT/TTS) does none of that inference on the Jetson at all. The Orin NX's job in that architecture is orchestration — capture audio, make an API call, play back the response — not running a model. CUDA version becomes almost irrelevant to the core loop, because the GPU-heavy work is happening somewhere else entirely, on hardware you don't have to reflash, patch, or maintain.</p>
<p>This isn't a theoretical alternative. It's the actual architecture this project ran on in its earliest working era, on stock JetPack 5.1.1, with a real conversational loop functioning end-to-end on unmodified platform software.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-you-get-by-staying-cloud-based">What you get by staying cloud-based<a href="https://robotomy.ai/blog/cloud-llm-escape-hatch#what-you-get-by-staying-cloud-based" class="hash-link" aria-label="Direct link to What you get by staying cloud-based" title="Direct link to What you get by staying cloud-based" translate="no">​</a></h2>
<ul>
<li class=""><strong>Zero platform-version fighting.</strong> No CUDA compatibility chasing, no hand-compiled binaries, no reflash. Stock JetPack works as shipped.</li>
<li class=""><strong>Access to frontier-quality models immediately.</strong> A hosted LLM is simply a stronger model than anything realistically runnable on-device on this hardware today. If response quality matters more than architecture purity, this is a real advantage, not a compromise.</li>
<li class=""><strong>Lower up-front engineering cost.</strong> An API call is a fraction of the integration work a local inference stack requires.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-it-actually-costs-you--the-honest-tradeoffs">What it actually costs you — the honest tradeoffs<a href="https://robotomy.ai/blog/cloud-llm-escape-hatch#what-it-actually-costs-you--the-honest-tradeoffs" class="hash-link" aria-label="Direct link to What it actually costs you — the honest tradeoffs" title="Direct link to What it actually costs you — the honest tradeoffs" translate="no">​</a></h2>
<p>This isn't a free lunch, and this project has direct, painful evidence of where it bites:</p>
<p><strong>Network dependency becomes a single point of failure for the entire conversational loop.</strong> This project's own <a class="" href="https://robotomy.ai/docs/log/safety-reliability/crash-not-wifi">demo-crash post-mortem</a> is directly relevant here — even though that specific incident turned out to be a software crash rather than a network drop, the initial (wrong) assumption that it <em>was</em> a WiFi failure was completely reasonable, precisely because a cloud-dependent architecture makes network loss a totally plausible, totally severe failure mode. A local-inference stack doesn't have this exposure at all — if the model's running on-device, a flaky venue WiFi doesn't take down your ability to think, even if it might still affect other things.</p>
<p><strong>Latency has a floor you don't control.</strong> Round-trip time to a cloud API, plus queueing on the provider's end, is added on top of whatever your own pipeline latency is — and unlike a local stack, you can't optimize it away by upgrading your own hardware.</p>
<p><strong>Cost scales with usage, indefinitely.</strong> A local model is a one-time hardware and setup cost. A cloud API is a per-call cost for the life of the deployment — worth actually modeling out for your expected usage pattern, not just accepting as a rounding error.</p>
<p><strong>Data leaves the device.</strong> For a project like this — audio captured from real conversations, in a public space, with children present — that's a real design consideration, not a footnote. Whether that's acceptable depends entirely on your specific deployment context and what commitments you're making to the people interacting with the robot.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-actual-decision-framework">The actual decision framework<a href="https://robotomy.ai/blog/cloud-llm-escape-hatch#the-actual-decision-framework" class="hash-link" aria-label="Direct link to The actual decision framework" title="Direct link to The actual decision framework" translate="no">​</a></h2>
<p>This isn't "cloud bad, local good" or the reverse — it's a genuine tradeoff, and the right answer depends on specifics:</p>
<ul>
<li class=""><strong>Reliability requirements matter more than raw model quality</strong> → lean local, accept the reflash, accept a smaller model</li>
<li class=""><strong>Response quality matters more than guaranteed uptime, and your deployment can tolerate occasional network-dependent failure</strong> → cloud is a completely legitimate, lower-effort choice</li>
<li class=""><strong>Data/privacy commitments are strict</strong> → local, regardless of the other factors</li>
<li class=""><strong>You're prototyping and don't yet know which of the above actually matters for your specific project</strong> → start cloud, since it's the lower-effort path to a working end-to-end loop, and revisit the reflash decision once you know what you actually need</li>
</ul>
<p>This project ultimately moved toward a local-first architecture — but that was a deliberate choice made after operating the cloud-based version and learning specifically where its tradeoffs bit, not a default assumption that local is inherently superior. If you're earlier in that same decision, it's worth actually running the cloud path first rather than reflashing on faith that local is the "correct" answer before you've felt what problem you're actually solving for.</p>]]></content:encoded>
            <category>JetPack</category>
            <category>CUDA</category>
        </item>
        <item>
            <title><![CDATA[Building a voice-to-action robot on stock JetPack 5.1.1: a complete engineering guide]]></title>
            <link>https://robotomy.ai/blog/complete-engineering-guide-cloud-stack</link>
            <guid>https://robotomy.ai/blog/complete-engineering-guide-cloud-stack</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[This is the guide we wish existed before we started. If you're building conversational, gesture-capable behavior on a Unitree G1 EDU — or any embedded robotics platform stuck on an older JetPack/CUDA version — and you're deciding whether a cloud-first stack makes sense, this walks through the real architecture, the real latency numbers, what actually worked, and where the hard edges are.]]></description>
            <content:encoded><![CDATA[<p>This is the guide we wish existed before we started. If you're building conversational, gesture-capable behavior on a Unitree G1 EDU — or any embedded robotics platform stuck on an older JetPack/CUDA version — and you're deciding whether a cloud-first stack makes sense, this walks through the real architecture, the real latency numbers, what actually worked, and where the hard edges are.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="who-this-is-for-and-the-core-bet">Who this is for, and the core bet<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#who-this-is-for-and-the-core-bet" class="hash-link" aria-label="Direct link to Who this is for, and the core bet" title="Direct link to Who this is for, and the core bet" translate="no">​</a></h2>
<p>If your platform is on JetPack 5.1.1 (CUDA 11.4, Python 3.8), you already know the ceiling: modern local-LLM tooling and current STT/TTS libraries mostly assume newer CUDA and Python than that platform provides (see <a class="" href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling">the hard-ceiling post</a> for the full technical reasoning). You have two real options: reflash to a newer JetPack, or go cloud-first and sidestep the ceiling entirely (see <a class="" href="https://robotomy.ai/blog/cloud-llm-escape-hatch">the companion escape-hatch post</a> for that tradeoff in full).</p>
<p>This guide covers the second path — a cloud-first stack that ran, in production, on stock JetPack 5.1.1, no reflash required. The core bet: a hosted STT engine, a hosted LLM, and a hosted TTS engine, orchestrated by a Jetson that does no heavy inference itself at all — just capture, routing, and playback.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-full-pipeline">The full pipeline<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#the-full-pipeline" class="hash-link" aria-label="Direct link to The full pipeline" title="Direct link to The full pipeline" translate="no">​</a></h2>
<!-- -->
<p>Each stage is covered in full technical detail in <a class="" href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era">the voice-to-action pipeline post</a> — this guide focuses on the numbers and the decisions, not re-explaining each stage from scratch.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-real-latency-budget-25-seconds-of-work-1-second-perceived">The real latency budget: ~2.5 seconds of work, ~1 second perceived<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#the-real-latency-budget-25-seconds-of-work-1-second-perceived" class="hash-link" aria-label="Direct link to The real latency budget: ~2.5 seconds of work, ~1 second perceived" title="Direct link to The real latency budget: ~2.5 seconds of work, ~1 second perceived" translate="no">​</a></h2>
<p>This is the number worth internalizing before you start: <strong>the actual end-to-end pipeline takes roughly 2.5 seconds from wake word to spoken response.</strong> That's not a failure — cloud STT, a frontier LLM call, and cloud TTS synthesis genuinely take real time, and no amount of client-side optimization changes the physics of three sequential network round-trips to hosted services.</p>
<p>What actually matters is that <strong>the perceived latency is closer to 1 second</strong>, and that gap is closed by a specific, deliberate trick, not a speed improvement:</p>
<!-- -->
<p>The mechanism: the instant a wake word fires, a <strong>pre-rendered opener WAV</strong> ("Oh, hello!") plays immediately, on a separate thread, in parallel with the real pipeline doing its actual work underneath. By the time that short opener finishes, the real response has usually arrived. The person experiences a robot that responded almost instantly — the actual 2.5-second pipeline never stopped running, it just wasn't the thing they were listening to.</p>
<p><strong>This is worth being honest about:</strong> it's a genuine UX trick, not a technical optimization. If you're building something similar, budget your actual pipeline at 2-3 seconds realistically, and plan your latency-masking strategy as a first-class design decision, not an afterthought bolted on after the fact.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-actually-worked-and-why">What actually worked, and why<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#what-actually-worked-and-why" class="hash-link" aria-label="Direct link to What actually worked, and why" title="Direct link to What actually worked, and why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Fuzzy wake-word matching</strong>, not exact string matching — real STT output reliably mishears wake words, and a <code>difflib</code>-based fuzzy match with an explicit list of known mishearings caught far more real attempts than exact matching ever would.</li>
<li class=""><strong>The model choosing its own gestures and emotional tags</strong>, embedded directly in its own text output (<code>[G:id]</code>, <code>[E:tag]</code>) — this is what made the interaction feel genuinely alive rather than scripted, and it's a capability worth taking seriously if you want a character that feels responsive rather than robotic.</li>
<li class=""><strong>A cleanly layered audio-output stage</strong> — swapping the final output hardware (external speaker to onboard speaker) required touching nothing upstream of that one stage. If you're designing something similar, keep your output stage as replaceable as this one was; it's a good sign the rest of the architecture is properly decoupled.</li>
<li class=""><strong>Session-aware word-count gating</strong> — different trigger thresholds inside vs. outside an active conversation session meant far less accidental triggering from ambient background speech, without needing a heavier, more complex intent-classification layer.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-the-hard-edges-are">Where the hard edges are<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#where-the-hard-edges-are" class="hash-link" aria-label="Direct link to Where the hard edges are" title="Direct link to Where the hard edges are" translate="no">​</a></h2>
<p>This stack has real, documented limits worth knowing before you commit to it:</p>
<ul>
<li class=""><strong>WebRTC as a gesture-control transport is genuinely fragile against vendor firmware updates</strong>, entirely outside your control — see <a class="" href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk">the full WebRTC saga</a> for exactly how badly this can go, and how long a fix can take even with an active, engaged community maintainer.</li>
<li class=""><strong>Only one active WebRTC session at a time</strong> — the vendor app and your own client cannot both be connected; plan your operational workflow (close the app before connecting) around this constraint explicitly, not as a surprise you discover live.</li>
<li class=""><strong>An LLM selecting its own physical actions is a real, physical-world risk surface</strong>, not just a conversational one — <a class="" href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#where-autonomy-went-further-than-intended">the Frankenstein incident</a> is the concrete example: two commands the model fired close together put the robot into a genuine lockup. If you give a model this kind of autonomy, plan real safety architecture around it from day one — see <a class="" href="https://robotomy.ai/docs/log/safety-reliability/safe-idle-two-tier-safety">the two-tier safety design</a> for the shape that took here.</li>
<li class=""><strong>A cloud-dependent architecture makes network loss a real, severe failure mode</strong> — see <a class="" href="https://robotomy.ai/docs/log/safety-reliability/crash-not-wifi">the demo-crash misdiagnosis</a> for a case where this was initially (wrongly) assumed to be the cause of a failure that turned out to be something else — but the fact that it was a <em>plausible</em> wrong guess tells you how real a risk this actually is.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision-framework-if-youre-starting-fresh">Decision framework, if you're starting fresh<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#decision-framework-if-youre-starting-fresh" class="hash-link" aria-label="Direct link to Decision framework, if you're starting fresh" title="Direct link to Decision framework, if you're starting fresh" translate="no">​</a></h2>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-honest-tradeoffs-one-more-time">The honest tradeoffs, one more time<a href="https://robotomy.ai/blog/complete-engineering-guide-cloud-stack#the-honest-tradeoffs-one-more-time" class="hash-link" aria-label="Direct link to The honest tradeoffs, one more time" title="Direct link to The honest tradeoffs, one more time" translate="no">​</a></h2>
<p>This isn't the objectively correct architecture — it's the right one for a specific set of constraints: no reflash tolerance, acceptable network dependency, and a real budget for handling the physical-safety implications of model-driven autonomy. If your constraints are different — strict data-privacy requirements, zero tolerance for network-dependent failure, or a platform where the reflash is cheap and low-risk — a different starting point is genuinely more correct for you, not just a matter of preference.</p>
<p>What we'd tell anyone starting this exact path: budget the real 2.5-second pipeline honestly, design your latency-masking strategy deliberately rather than as an afterthought, and build your safety architecture <em>before</em> you give a model the ability to act in the physical world — not after the first incident makes it obvious you needed to.</p>]]></content:encoded>
            <category>JetPack</category>
            <category>Pipecat</category>
            <category>WebRTC</category>
            <category>Audio</category>
        </item>
        <item>
            <title><![CDATA[From motion-capture dreams to a natural-language gesture tool]]></title>
            <link>https://robotomy.ai/blog/gesture-authoring-tool-story</link>
            <guid>https://robotomy.ai/blog/gesture-authoring-tool-story</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[This is a story about a tool that ended up nothing like what it started as — and about how the detour turned out to be the better path anyway. It starts with wanting to record real human motion for the robot to mimic, and ends with describing a gesture in plain English and getting back a validated, ready-to-run script.]]></description>
            <content:encoded><![CDATA[<p>This is a story about a tool that ended up nothing like what it started as — and about how the detour turned out to be the better path anyway. It starts with wanting to record real human motion for the robot to mimic, and ends with describing a gesture in plain English and getting back a validated, ready-to-run script.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-starting-idea-motion-capture">The starting idea: motion capture<a href="https://robotomy.ai/blog/gesture-authoring-tool-story#the-starting-idea-motion-capture" class="hash-link" aria-label="Direct link to The starting idea: motion capture" title="Direct link to The starting idea: motion capture" translate="no">​</a></h2>
<p>The initial plan was straightforward: use <code>xr_teleoperate</code>, a motion-capture pipeline that maps a human operator's body pose (captured via VR headset) onto the robot's joint angles, to record real gestures by literally performing them.</p>
<p>The problem showed up immediately: <code>xr_teleoperate</code>'s officially supported hardware list includes Meta Quest 3 and 3S — not the Quest 2 headsets actually on hand. Two paths presented themselves at that point: sink real time into getting unsupported hardware working against a pipeline that doesn't officially support it, or find another way entirely.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pivot-the-data-already-existed">The pivot: the data already existed<a href="https://robotomy.ai/blog/gesture-authoring-tool-story#the-pivot-the-data-already-existed" class="hash-link" aria-label="Direct link to The pivot: the data already existed" title="Direct link to The pivot: the data already existed" translate="no">​</a></h2>
<p>Rather than chase Quest 2 compatibility, the actual question worth asking was simpler: was live motion capture even necessary, given what was already sitting on disk? It was not. <strong>LAFAN1 G1-retargeted motion CSVs</strong> and the <strong><code>openhe/g1-retargeted-motions</code></strong> dataset — both already confirmed working in MuJoCo simulation — represented a substantial, ready-to-use library of real human motion data, already mapped to this exact robot's joint structure. No capture hardware required at all.</p>
<p>This is a pattern worth naming on its own: <strong>the instinct to build new capture infrastructure is often solving a problem that existing, already-validated data has already solved.</strong> Before investing in hardware or a live-capture pipeline, it's worth genuinely checking what usable data already exists — in this case, an entire viable gesture-authoring foundation was already sitting on disk, unused, simply because the original plan hadn't gone looking for it first.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-real-idea-skip-capture-entirely-describe-it-in-english-instead">The real idea: skip capture entirely, describe it in English instead<a href="https://robotomy.ai/blog/gesture-authoring-tool-story#the-real-idea-skip-capture-entirely-describe-it-in-english-instead" class="hash-link" aria-label="Direct link to The real idea: skip capture entirely, describe it in English instead" title="Direct link to The real idea: skip capture entirely, describe it in English instead" translate="no">​</a></h2>
<p>The pivot led somewhere better than a simple substitution of one data source for another. If joint-angle data for gestures could come from existing datasets, why not go a step further — describe a <em>new</em> gesture in plain language, and have an LLM reason about the actual joint angles needed to produce it?</p>
<p>The design: a natural-language-to-joint-angle authoring tool. A person describes a gesture in plain English. The Claude API reasons about G1 joint angles <strong>anatomically</strong>, using a real joint reference table (indices 10–26, with documented safe min/max limits for each), and produces keyframes. Those keyframes get validated against joint limits and velocity thresholds, then exported as a ready-to-deploy Python script matching the existing <code>rt/arm_sdk</code> gesture-dispatch pattern exactly.</p>
<p>This was explicitly framed from the start as a small step toward a larger goal: the <code>[G:tag]</code> naming convention used for authored gestures is the same one that eventually lets an LLM running the live conversational pipeline autonomously trigger validated gesture scripts — this tool and the live dispatch system share the same underlying contract by design, not by coincidence.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="building-it-three-iterations-and-an-honest-dead-end">Building it: three iterations, and an honest dead end<a href="https://robotomy.ai/blog/gesture-authoring-tool-story#building-it-three-iterations-and-an-honest-dead-end" class="hash-link" aria-label="Direct link to Building it: three iterations, and an honest dead end" title="Direct link to Building it: three iterations, and an honest dead end" translate="no">​</a></h2>
<p>The first attempts built this as an in-browser artifact — and hit real friction: sandbox API blocking, and a click-handler scoping bug that took a deliberate, minimal reproduction (a bare-bones click test confirming an IIFE-plus-<code>addEventListener</code> pattern actually worked in that environment) to properly isolate. Worth naming plainly: this wasn't solved on the first or even second attempt.</p>
<p>The actual fix wasn't a deeper artifact-environment workaround — it was recognizing the artifact sandbox was the wrong delivery format for this specific tool, and shipping it as a standalone local HTML file instead. That's a similar shape of decision to <a class="" href="https://robotomy.ai/docs/log/platform-cuda/containerize-dont-fight-the-wheel">containerizing rather than fighting a missing CUDA wheel</a> elsewhere in this project: when an environment's constraints keep fighting you, the fix is sometimes picking a different environment entirely, not eventually out-stubborning the original one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-actually-shipped">What actually shipped<a href="https://robotomy.ai/blog/gesture-authoring-tool-story#what-actually-shipped" class="hash-link" aria-label="Direct link to What actually shipped" title="Direct link to What actually shipped" translate="no">​</a></h2>
<p>A self-contained <code>gesture_tool.html</code>, structured as five stages: <strong>Describe → Keyframes → Validate → Export → Library.</strong> It handles its own Claude API key (saved locally, provided by whoever's running the tool), uses cosine-eased keyframe interpolation for natural-looking motion, validates against real joint limits and velocity thresholds, estimates center-of-mass to catch balance-affecting gestures before they ever reach hardware, and generates Python output matching the live pipeline's existing <code>rt/arm_sdk</code> format exactly — so an authored gesture and a live-dispatched one are structurally identical, not two different code shapes that happen to do similar things.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-whole-detour-mattered">Why this whole detour mattered<a href="https://robotomy.ai/blog/gesture-authoring-tool-story#why-this-whole-detour-mattered" class="hash-link" aria-label="Direct link to Why this whole detour mattered" title="Direct link to Why this whole detour mattered" translate="no">​</a></h2>
<p>None of this would have happened if Quest 2 compatibility had just worked on the first attempt. The constraint — no supported capture hardware on hand — is what forced a genuine second look at what already existed, which surfaced better data than live capture likely would have produced anyway, and led to a more capable, more directly integrated tool than "record some gestures with a headset" was ever going to be. Worth remembering the next time a missing piece of hardware feels like a pure blocker: sometimes the workaround it forces is better than the original plan would have been.</p>]]></content:encoded>
            <category>Pipecat</category>
        </item>
        <item>
            <title><![CDATA[How the face actually works: a real-time expression engine in one process]]></title>
            <link>https://robotomy.ai/blog/how-the-face-works</link>
            <guid>https://robotomy.ai/blog/how-the-face-works</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[Voice and gestures get most of the attention in this project, but the face is doing genuinely interesting real-time work of its own — fifteen parameters lerping independently, Perlin-noise-driven mouth movement instead of robotic on/off animation, and speech-synced expression timing that has to stay accurate across a live, streaming audio pipeline. This is how it's built.]]></description>
            <content:encoded><![CDATA[<p>Voice and gestures get most of the attention in this project, but the face is doing genuinely interesting real-time work of its own — fifteen parameters lerping independently, Perlin-noise-driven mouth movement instead of robotic on/off animation, and speech-synced expression timing that has to stay accurate across a live, streaming audio pipeline. This is how it's built.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-process-three-threads-one-render-loop">One process, three threads, one render loop<a href="https://robotomy.ai/blog/how-the-face-works#one-process-three-threads-one-render-loop" class="hash-link" aria-label="Direct link to One process, three threads, one render loop" title="Direct link to One process, three threads, one render loop" translate="no">​</a></h2>
<p>The whole face system runs as a single unified process — not a distributed set of services, deliberately. Three daemon threads handle the actual work underneath a Pygame render loop running at 30fps:</p>
<ul>
<li class=""><strong>A TTS worker thread</strong>, calling ElevenLabs</li>
<li class=""><strong>An audio playback thread</strong>, via <code>sounddevice</code></li>
<li class=""><strong>An input worker thread</strong>, handling whatever's driving the conversation (text input, or the live pipeline output)</li>
</ul>
<p>The render loop itself just reads current state and draws — it doesn't own any of the actual audio or network work, which keeps the visual side responsive regardless of what the other threads are doing. One nice practical consequence of this architecture: there are no pipeline restarts needed to iterate on expressions. Edit the face-rendering file directly, and the changes take effect immediately, without touching anything upstream.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="facestate-fifteen-numbers-each-moving-at-its-own-speed">FaceState: fifteen numbers, each moving at its own speed<a href="https://robotomy.ai/blog/how-the-face-works#facestate-fifteen-numbers-each-moving-at-its-own-speed" class="hash-link" aria-label="Direct link to FaceState: fifteen numbers, each moving at its own speed" title="Direct link to FaceState: fifteen numbers, each moving at its own speed" translate="no">​</a></h2>
<p>The core of the whole system is a <code>FaceState</code> dataclass — fifteen floating-point parameters (eye openness, brow position, mouth shape components, and more), each one <strong>lerping toward its target value at its own differentiated speed</strong>, not a single global animation rate.</p>
<p>This matters more than it might look like on paper. A single shared animation speed makes every expression change feel the same — uniformly snappy or uniformly sluggish, regardless of what's actually happening. Differentiated speeds let a blink snap shut fast while a brow raise settles in more slowly, which is a lot closer to how real facial movement actually reads: different muscles, different speeds, not one uniform "animation."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="blinking-and-micro-movement">Blinking and micro-movement<a href="https://robotomy.ai/blog/how-the-face-works#blinking-and-micro-movement" class="hash-link" aria-label="Direct link to Blinking and micro-movement" title="Direct link to Blinking and micro-movement" translate="no">​</a></h2>
<p>Blinks use cubic easing rather than linear interpolation — a blink that eases in and out reads as much more natural than one that moves at a constant rate, since real blinks aren't linear either. On top of that, small <strong>saccade micro-movements</strong> run continuously in the background — the tiny, near-constant eye movements real eyes make even when "still." Without this, a face that's technically animated correctly can still read as dead or uncanny, because real eyes are never actually motionless.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-mouth-perlin-noise-not-a-volume-meter">The mouth: Perlin noise, not a volume meter<a href="https://robotomy.ai/blog/how-the-face-works#the-mouth-perlin-noise-not-a-volume-meter" class="hash-link" aria-label="Direct link to The mouth: Perlin noise, not a volume meter" title="Direct link to The mouth: Perlin noise, not a volume meter" translate="no">​</a></h2>
<p>This is the detail worth dwelling on. A naive approach to mouth animation during speech is to drive mouth openness directly off the audio's volume envelope — louder means more open. That works, but it reads as mechanical, because real speech doesn't just vary in loudness, it has continuous, organic shape variation even at a constant volume.</p>
<p>The actual approach layers <strong>Perlin noise</strong> — a smooth, continuous pseudo-random function, commonly used in graphics for natural-looking terrain and motion — on top of the coarticulation/viseme interpolation driving the base mouth shape. The Perlin layer adds the small, continuous, non-repeating variation that makes the mouth look like it's actually shaping sounds, rather than mechanically opening and closing in sync with a volume meter.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="keeping-lip-sync-accurate-across-a-streaming-pipeline">Keeping lip sync accurate across a streaming pipeline<a href="https://robotomy.ai/blog/how-the-face-works#keeping-lip-sync-accurate-across-a-streaming-pipeline" class="hash-link" aria-label="Direct link to Keeping lip sync accurate across a streaming pipeline" title="Direct link to Keeping lip sync accurate across a streaming pipeline" translate="no">​</a></h2>
<p>The actual viseme-timing math — correcting for ElevenLabs' per-chunk relative timestamps and the sample-rate mismatch between ElevenLabs' output and the playback pipeline — is covered in full technical detail in <a class="" href="https://robotomy.ai/docs/log/audio-voice/elevenlabs-viseme-timing">the ElevenLabs viseme timing entry</a>. The piece that connects to the face system specifically is the anchor pattern: <strong>a single <code>T0</code> timestamp is recorded the instant <code>sound.play()</code> actually starts</strong>, and every subsequent viseme or expression event is scheduled as an offset from that one anchor (<code>elapsed = time.time() - T0</code>), rather than trying to track time independently in each subsystem. One shared anchor, many events scheduled relative to it — simple, and avoids any drift between what the mouth is doing and what's actually coming out of the speaker.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-design-holds-together">Why this design holds together<a href="https://robotomy.ai/blog/how-the-face-works#why-this-design-holds-together" class="hash-link" aria-label="Direct link to Why this design holds together" title="Direct link to Why this design holds together" translate="no">​</a></h2>
<p>Every piece of this system solves a version of the same problem: <strong>make discrete, computed state look like continuous, organic movement.</strong> Differentiated lerp speeds instead of one animation rate. Cubic easing instead of linear blinks. Saccades instead of a static gaze. Perlin noise instead of a volume meter. A single timing anchor instead of independently-drifting clocks. None of these tricks are individually complicated — the discipline is in applying the same underlying idea consistently across every part of the face, rather than solving expressiveness in only the most visible place (the mouth) and leaving everything else mechanical by comparison.</p>]]></content:encoded>
            <category>Audio</category>
            <category>Pipecat</category>
        </item>
        <item>
            <title><![CDATA[The hard ceiling: why JetPack 5.1.1 makes modern embodied AI on the G1 EDU a dead end]]></title>
            <link>https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling</link>
            <guid>https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[If you're building an embodied AI project on the Unitree G1 EDU, and you're still on the stock JetPack 5.1.1 platform, there's a decision point coming that no amount of clever engineering will let you avoid — if local, on-device inference is your goal. This post is about why that ceiling is real for that specific goal, why the obvious workarounds don't actually work, and why the decision to stop patching around it was the right one to make earlier rather than later. (If local inference isn't actually required for your project, the ceiling may not apply to you at all — see the companion post on the cloud-LLM path before assuming you need to reflash.)]]></description>
            <content:encoded><![CDATA[<p>If you're building an embodied AI project on the Unitree G1 EDU, and you're still on the stock JetPack 5.1.1 platform, there's a decision point coming that no amount of clever engineering will let you avoid — <strong>if local, on-device inference is your goal.</strong> This post is about why that ceiling is real for that specific goal, why the obvious workarounds don't actually work, and why the decision to stop patching around it was the right one to make earlier rather than later. (If local inference isn't actually required for your project, the ceiling may not apply to you at all — see the <a class="" href="https://robotomy.ai/blog/cloud-llm-escape-hatch">companion post on the cloud-LLM path</a> before assuming you need to reflash.)</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-ceiling-concretely">The ceiling, concretely<a href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling#the-ceiling-concretely" class="hash-link" aria-label="Direct link to The ceiling, concretely" title="Direct link to The ceiling, concretely" translate="no">​</a></h2>
<p>The G1 EDU ships with JetPack 5.1.1: Python 3.8, CUDA 11.4. On paper, that's a perfectly capable embedded AI platform. In practice, it quietly locks you out of the current generation of local-AI tooling, for one specific, unglamorous reason: <strong>Ollama's GPU backends require a newer CUDA than 11.4 provides, full stop.</strong> Not slower. Not degraded. Incompatible.</p>
<p>That single fact cascades further than it looks like it should. Most current speech-to-text and text-to-speech libraries assume Python ≥3.10 as a baseline. Whisper's ecosystem, Pipecat, and a good chunk of the current voice-pipeline tooling generally, all lean on language and packaging features that Python 3.8 doesn't have. So the ceiling isn't just "no Ollama" — it's "no current local-LLM tooling, and increasingly awkward footing for current STT/TTS tooling too," all stemming from one version pin.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-workaround-we-actually-tried-and-why-it-was-a-dead-end">The workaround we actually tried, and why it was a dead end<a href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling#the-workaround-we-actually-tried-and-why-it-was-a-dead-end" class="hash-link" aria-label="Direct link to The workaround we actually tried, and why it was a dead end" title="Direct link to The workaround we actually tried, and why it was a dead end" translate="no">​</a></h2>
<p>The instinct, reasonably, was: don't reflash the whole robot over a version number — compile around it. So that's what got built first: <code>llama.cpp</code>, compiled from source, targeting CUDA 11.4 and <code>compute_87</code> explicitly, running local LLM inference in-process.</p>
<p>It worked. That's worth saying plainly — this wasn't a failed experiment, it was a functioning, hand-built inference path that ran real models on real hardware.</p>
<p>But "worked" and "worked for the long haul" are different claims. A hand-compiled binary, targeting an old CUDA version by hand, has no upstream maintainer keeping it aligned with anything. Every future dependency — a new STT library, a new pipeline framework version, a new model architecture — was a fresh compatibility check against a stack nobody but this project was responsible for keeping current. That's not a one-time cost. It's a permanent tax, paid again on every future addition to the stack, for as long as the platform stayed on JetPack 5.1.1.</p>
<p>This is the same shape of problem documented elsewhere on this site in <a class="" href="https://robotomy.ai/docs/log/platform-cuda/containerize-dont-fight-the-wheel">containerizing rather than hand-compiling CUDA-dependent packages</a> — except in this case, there was no container that could fix it, because the ceiling wasn't in a single package's missing wheel. It was the platform itself.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-patching-further-wasnt-the-answer">Why patching further wasn't the answer<a href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling#why-patching-further-wasnt-the-answer" class="hash-link" aria-label="Direct link to Why patching further wasn't the answer" title="Direct link to Why patching further wasn't the answer" translate="no">​</a></h2>
<p>At some point, the honest question stopped being "what's the next workaround" and became "is this ceiling actually fixable in place, or is it structural." It's structural. CUDA 11.4 is a platform-level constraint tied to JetPack 5.1.1's driver stack — there's no user-space fix, no pip flag, no clever container trick that changes what CUDA version the underlying platform actually exposes. Every additional hand-compiled workaround was solving today's specific blocker while leaving the actual ceiling completely intact for the next one.</p>
<p>That's the moment worth naming explicitly for anyone else facing the same fork: <strong>if the blocker is a platform version, not a package version, workarounds are borrowed time, not a fix.</strong> The right question isn't "can I get this one thing working" — it's "how many more times am I going to ask that question before the answer is the same reflash I'm avoiding right now."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-reflash-actually-bought">What the reflash actually bought<a href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling#what-the-reflash-actually-bought" class="hash-link" aria-label="Direct link to What the reflash actually bought" title="Direct link to What the reflash actually bought" translate="no">​</a></h2>
<p>JetPack 6.2 removes the ceiling entirely — Python 3.11, CUDA 12.6, and critically, Ollama's GPU backends work as intended, with zero hand-compiled anything. The full procedure — disassembly, NVMe flash, separate NX module firmware update, verified platform state afterward — is documented in detail in <a class="" href="https://robotomy.ai/docs/log/platform-cuda/jp62-reflash">the reflash log entry</a> if you're about to do this yourself.</p>
<p>The bigger shift wasn't really "newer versions" for their own sake. It's that the entire dependency stack going forward — Ollama, faster-whisper, Pipecat, all of it — collapsed into a single, normal, maintainable Python 3.11 environment instead of a hand-tuned exception. That's the actual payoff: not speed, not features, but no longer being the only maintainer of a compatibility layer that upstream projects were never going to support.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="if-youre-deciding-whether-to-reflash">If you're deciding whether to reflash<a href="https://robotomy.ai/blog/jetpack-5-1-1-hard-ceiling#if-youre-deciding-whether-to-reflash" class="hash-link" aria-label="Direct link to If you're deciding whether to reflash" title="Direct link to If you're deciding whether to reflash" translate="no">​</a></h2>
<p>A few honest questions worth asking before you commit, based on what this project actually hit:</p>
<ul>
<li class=""><strong>Are you trying to run any current local-LLM tooling (Ollama, or anything GPU-accelerated) on stock JetPack 5.1.1?</strong> If yes, you will hit this exact wall, not a softer version of it.</li>
<li class=""><strong>Are you hand-compiling anything against CUDA 11.4 right now to work around it?</strong> If yes, you're already paying the tax this post describes — the only question left is whether you keep paying it or reflash.</li>
<li class=""><strong>Is your project's timeline measured in months, not days?</strong> If so, the reflash is worth doing sooner rather than later — every workaround built in the meantime is something you'll either maintain indefinitely or eventually throw away.</li>
</ul>
<p>If none of that applies yet — if you're early, prototyping, not yet asking your platform to do anything CUDA-12-era — there's no reason to reflash preemptively. But the moment any of those three is true, the ceiling stops being theoretical.</p>]]></content:encoded>
            <category>JetPack</category>
            <category>CUDA</category>
        </item>
        <item>
            <title><![CDATA[How Project Ziko actually worked: the voice-to-action pipeline behind the April 11 demo]]></title>
            <link>https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era</link>
            <guid>https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[The WebRTC saga post covers what broke after April 11. This post is about what was actually running underneath the demo itself — the full loop from a person speaking to the robot physically responding, stage by stage, in enough detail to actually follow along if you're building something similar.]]></description>
            <content:encoded><![CDATA[<p>The <a class="" href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk">WebRTC saga post</a> covers what broke after April 11. This post is about what was actually running underneath the demo itself — the full loop from a person speaking to the robot physically responding, stage by stage, in enough detail to actually follow along if you're building something similar.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-shape-of-the-loop">The shape of the loop<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#the-shape-of-the-loop" class="hash-link" aria-label="Direct link to The shape of the loop" title="Direct link to The shape of the loop" translate="no">​</a></h2>
<!-- -->
<p>Nine real stages, each with its own specific gotcha worth knowing if you're replicating this.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-mic-capture-and-echo-cancellation">1. Mic capture and echo cancellation<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#1-mic-capture-and-echo-cancellation" class="hash-link" aria-label="Direct link to 1. Mic capture and echo cancellation" title="Direct link to 1. Mic capture and echo cancellation" translate="no">​</a></h2>
<p>A USB mic feeds into PulseAudio with <code>module-echo-cancel</code> (speex) active — <code>ec_mic</code> as the working capture device, not the raw hardware input. This matters because the robot's own speaker output would otherwise bleed back into the mic and get picked up as fresh input, which is exactly the kind of feedback loop that makes a voice pipeline unusable in a real room with real reflections and real ambient noise.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-streaming-stt--and-a-specific-trigger-discipline">2. Streaming STT — and a specific trigger discipline<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#2-streaming-stt--and-a-specific-trigger-discipline" class="hash-link" aria-label="Direct link to 2. Streaming STT — and a specific trigger discipline" title="Direct link to 2. Streaming STT — and a specific trigger discipline" translate="no">​</a></h2>
<p>Deepgram's <code>nova-2</code> model, connected via streaming WebSocket, not batch. The callback that actually matters only fires on <strong>both</strong> <code>is_final=True</code> <strong>and</strong> <code>speech_final=True</code> — not just one or the other. That distinction is easy to get wrong: <code>is_final</code> alone can fire on interim segment boundaries mid-utterance, while <code>speech_final</code> specifically signals the speaker has actually stopped. Triggering on <code>is_final</code> alone risks firing the pipeline on a sentence fragment.</p>
<p>An automatic 2-second retry on Deepgram disconnect keeps the stream resilient without requiring a full pipeline restart on a transient network blip.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-wake-word--fuzzy-not-exact">3. Wake word — fuzzy, not exact<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#3-wake-word--fuzzy-not-exact" class="hash-link" aria-label="Direct link to 3. Wake word — fuzzy, not exact" title="Direct link to 3. Wake word — fuzzy, not exact" translate="no">​</a></h2>
<p>Exact-string wake word matching is fragile against real STT output, which reliably mishears things. The actual match list included the obvious target plus every plausible mishearing Deepgram had actually produced in testing — "rubbert," "bberg," "rupert," "brewer," "grouper" — plus a fallback rule matching any word starting with "ru" or the project's actual name (≥3 characters). A <code>difflib</code> fuzzy cutoff of 0.75 catches variants beyond even that explicit list.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-session-and-echo-gate-logic">4. Session and echo-gate logic<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#4-session-and-echo-gate-logic" class="hash-link" aria-label="Direct link to 4. Session and echo-gate logic" title="Direct link to 4. Session and echo-gate logic" translate="no">​</a></h2>
<p>Once a wake word fires, a session stays open for 60 seconds after the last interaction — not a global timeout, one that resets on every exchange. Word-count gating differs depending on whether you're inside or outside an active session: outside, a minimum 5 words are required to trigger (filtering out ambient noise and stray fragments); inside an active session, that drops to 3 words, since the system already has strong prior evidence someone is actively engaged. Barge-in is supported — saying the wake word again during playback interrupts the current response — and a small set of explicit stop words (like "stop," "quiet," "cancel," "enough") terminate playback immediately.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-the-llm-call-and-the-latency-masking-trick">5. The LLM call, and the latency-masking trick<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#5-the-llm-call-and-the-latency-masking-trick" class="hash-link" aria-label="Direct link to 5. The LLM call, and the latency-masking trick" title="Direct link to 5. The LLM call, and the latency-masking trick" translate="no">​</a></h2>
<p>GPT-4o, streaming, with a 6-message rolling history (three exchanges) — enough for real conversational continuity without an unbounded, ever-growing context.</p>
<p>The genuinely clever part: the moment a wake word fires, a pre-cached <strong>opener WAV</strong> ("Oh, hello!", "I'm listening!") plays immediately in parallel with the GPT-4o call, on a separate thread. By the time that short opener finishes playing, the real response has usually already arrived — so perceived latency drops well below the pipeline's actual end-to-end time, without any real speedup having happened at all. It's a genuine trick, not a technical fix, and it works because it's targeting <em>perceived</em> responsiveness specifically, not raw pipeline speed.</p>
<p>Opener WAVs themselves are pre-rendered once at startup via the TTS engine's REST API and cached locally at 16kHz mono 16-bit — no live synthesis cost paid for this specific latency-masking step.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="6-tags-embedded-directly-in-the-models-own-output">6. Tags embedded directly in the model's own output<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#6-tags-embedded-directly-in-the-models-own-output" class="hash-link" aria-label="Direct link to 6. Tags embedded directly in the model's own output" title="Direct link to 6. Tags embedded directly in the model's own output" translate="no">​</a></h2>
<p>This is the architectural core of the whole system: GPT-4o's system prompt instructs it to embed <strong>its own</strong> gesture and emotion selections directly into its text response — <code>[G:N]</code> for a numeric gesture ID, <code>[E:tag]</code> for an emotion/expression tag — interleaved with the actual spoken reply. A tag parser strips both before the text reaches TTS, and routes the extracted tags to their own dispatchers.</p>
<p>Worth being explicit about what this means: <strong>the model is choosing its own physical actions</strong>, inferred from its own read of the conversation, not selected by any separate rule-based layer. That's the capability — and, as covered further down, also the risk.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="7-text-to-speech">7. Text-to-speech<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#7-text-to-speech" class="hash-link" aria-label="Direct link to 7. Text-to-speech" title="Direct link to 7. Text-to-speech" translate="no">​</a></h2>
<p>Deepgram Aura, <code>aura-orion-en</code>, roughly 300–500ms per sentence for live generation — plus the pre-rendered WAV cache covering the fixed opener set, avoiding that latency entirely for the specific phrases used there.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="8-audio-output--and-the-one-thing-that-changed-later">8. Audio output — and the one thing that changed later<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#8-audio-output--and-the-one-thing-that-changed-later" class="hash-link" aria-label="Direct link to 8. Audio output — and the one thing that changed later" title="Direct link to 8. Audio output — and the one thing that changed later" translate="no">​</a></h2>
<p>Two different eras of this same pipeline used two different final output stages: earlier, audio went through <code>sounddevice</code> to an external Rokono speaker; later, it switched to <code>AudioClient.PlayStream()</code> direct to the robot's own onboard speaker. The genuinely notable thing about this swap: <strong>nothing else in the pipeline changed.</strong> Same mic, same GPT-4o, same TTS engine, same tag system — only the last few inches of the audio path were different. That's a good sign of a cleanly-layered architecture: a real change to the output hardware didn't ripple backward into anything upstream of it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="9-gesture-dispatch--the-automatic-path-as-it-ran-on-april-11">9. Gesture dispatch — the automatic path, as it ran on April 11<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#9-gesture-dispatch--the-automatic-path-as-it-ran-on-april-11" class="hash-link" aria-label="Direct link to 9. Gesture dispatch — the automatic path, as it ran on April 11" title="Direct link to 9. Gesture dispatch — the automatic path, as it ran on April 11" translate="no">​</a></h2>
<p><code>[G:N]</code> tags, once parsed, fired via <code>api_id 7106</code> (built-in gestures) over the custom WebRTC connection — the same connection and topic covered in detail in <a class="" href="https://robotomy.ai/docs/log/webrtc-gestures/webrtc-arm-topic-discovery">the arm-topic-discovery entry</a>. This is the part that made the April 11 demo feel genuinely alive rather than scripted: the robot's physical gestures were a direct, live consequence of what the model itself decided to say and how it decided to say it — not a fixed animation triggered by a human operator watching from the side.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-bought-a-real-working-autonomous-loop">What this bought: a real, working, autonomous loop<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#what-this-bought-a-real-working-autonomous-loop" class="hash-link" aria-label="Direct link to What this bought: a real, working, autonomous loop" title="Direct link to What this bought: a real, working, autonomous loop" translate="no">​</a></h2>
<p>Worth stating plainly, because it's the whole point: this is Human → Speech → LLM → Decision → Gesture → Physical response, running live, with no human in the loop selecting what the robot did or said in the moment. That's not a demo of individual components — it's a functioning autonomous interaction loop, and it's genuinely what was running on April 11.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-autonomy-went-further-than-intended">Where autonomy went further than intended<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#where-autonomy-went-further-than-intended" class="hash-link" aria-label="Direct link to Where autonomy went further than intended" title="Direct link to Where autonomy went further than intended" translate="no">​</a></h2>
<p>:::note A different day — not April 11
The incident below happened during a separate, private demo session — <strong>not</strong> the April 11 open house, which was a full success as covered in the WebRTC saga post. Keeping these clearly separate matters, since they're easy to conflate.
:::</p>
<p>The same capability that made the April 11 demo feel alive — the model choosing its own gestures based on its own read of the conversation — went further than intended on a different occasion. GPT-4o interpreted a person's tone as sad, and, working entirely from its own instruction base and the local gesture-ID library available to it, inferred on its own that the appropriate response was to physically approach the person with arms extended — a gesture nobody had explicitly authored or instructed it to select for that situation. The walk itself was rough — a short, uncertain shuffle rather than a confident stride — but it was real locomotion, autonomously triggered.</p>
<p>The lockup was severe enough that only a full reboot cleared it — no software-level recovery, no reset command, nothing short of power-cycling the robot brought it back to a working state. During the lockup itself, communication errors surfaced in the Unitree Explore app, consistent with the robot's control layer having entered a state it genuinely couldn't resolve on its own.</p>
<p>The deeper issue: the robot's control layer at the time was only willing to accept one command context at a time. The locomotion attempt and the arm gesture, dispatched close together, put the system into a state it couldn't cleanly resolve — a real lockup, not a graceful failure. This is a different failure mode from <a class="" href="https://robotomy.ai/docs/log/webrtc-gestures/fsm-gating-custom-gestures">the FSM-gating issue already documented</a> — that one silently no-ops a command that returns success without ever moving; this one is a genuine conflict between two commands actively racing each other for the same control context, severe enough to require a full reboot rather than any in-session recovery.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-incident-actually-revealed">What this incident actually revealed<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#what-this-incident-actually-revealed" class="hash-link" aria-label="Direct link to What this incident actually revealed" title="Direct link to What this incident actually revealed" translate="no">​</a></h2>
<p>Notably, this is very plausibly <em>why</em> the eventual, more mature gesture architecture moved to <code>rt/arm_sdk</code> with an explicit <strong>weight-blending parameter</strong> — a design that lets arm commands layer smoothly on top of locomotion output (ramping control weight from 0 to 1 and back) rather than treating the two as separate, competing command contexts at all. The later architecture's own documentation states outright: locomotion and arm control run as genuinely parallel channels, specifically so that "the robot keeps walking and balancing while your gesture script controls only the arm joints" — a direct structural fix for the exact class of conflict this incident surfaced.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-honest-tradeoff-of-an-llm-calling-its-own-physical-actions">The honest tradeoff of an LLM calling its own physical actions<a href="https://robotomy.ai/blog/voice-to-action-pipeline-brewbert-era#the-honest-tradeoff-of-an-llm-calling-its-own-physical-actions" class="hash-link" aria-label="Direct link to The honest tradeoff of an LLM calling its own physical actions" title="Direct link to The honest tradeoff of an LLM calling its own physical actions" translate="no">​</a></h2>
<p>This is the real lesson, not just an entertaining anecdote: giving a language model the ability to select and fire its own physical actions, based on its own inference about what's appropriate, is a genuinely powerful way to make a robot feel alive and responsive — and it comes with a real, physical-world risk surface that a purely conversational chatbot never has to reckon with. A bad text response is embarrassing. A bad physical-action inference, on real hardware, moving toward a real person, is a different category of risk entirely.</p>
<p>This incident is a big part of why later architecture put real structure around autonomous action-calling — the <a class="" href="https://robotomy.ai/docs/log/webrtc-gestures/gesture-arbitrator-drop-not-queue">gesture arbitrator's strict drop-lock design</a>, a <a class="" href="https://robotomy.ai/docs/log/webrtc-gestures/gesture-allowlist-safety-requirement">human-curated gesture allowlist</a> restricting which gestures a model can select from at all, and the <a class="" href="https://robotomy.ai/docs/log/safety-reliability/safe-idle-two-tier-safety">two-tier SAFE_IDLE safety system</a> all exist, in part, because of exactly this class of incident: an autonomous system doing something technically-successful-but-contextually-wrong, with real physical consequences if left unchecked.</p>]]></content:encoded>
            <category>Pipecat</category>
            <category>Audio</category>
            <category>WebRTC</category>
        </item>
        <item>
            <title><![CDATA[The April 11 demo worked. Then a firmware update took it away.]]></title>
            <link>https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk</link>
            <guid>https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk</guid>
            <pubDate>Thu, 16 Jul 2026 23:26:55 GMT</pubDate>
            <description><![CDATA[On April 11, 2026, the first real demo of Project Ziko ran at an open house — voice pipeline working end to end, wake word firing reliably, built-in gestures dispatched live by GPT-4o through a WebRTC connection to the robot. It was a full success. Six days later, the robot was non-operational, and it would take weeks, a community investigation, and eventually a full architectural pivot away from WebRTC before gesture control was genuinely solid again. This is that arc.]]></description>
            <content:encoded><![CDATA[<p>On April 11, 2026, the first real demo of Project Ziko ran at an open house — voice pipeline working end to end, wake word firing reliably, built-in gestures dispatched live by GPT-4o through a WebRTC connection to the robot. It was a full success. Six days later, the robot was non-operational, and it would take weeks, a community investigation, and eventually a full architectural pivot away from WebRTC before gesture control was genuinely solid again. This is that arc.</p>
<p>Scoping note: the WebRTC break was the most visible failure to come out of firmware 1.5.1, but it wasn't the only one. The same update also killed the internal speaker, introduced new motor alarms, and surfaced a separate (unrelated) custom-gesture failure — four compounding problems off one release, not one. This post follows the WebRTC thread specifically; the full triage across all four is in <a class="" href="https://robotomy.ai/docs/log/hardware-diagnostics/1.5.1-systemic-regression">the 1.5.1 systemic-regression entry</a>.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-was-actually-working-on-april-11">What was actually working on April 11<a href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk#what-was-actually-working-on-april-11" class="hash-link" aria-label="Direct link to What was actually working on April 11" title="Direct link to What was actually working on April 11" translate="no">​</a></h2>
<p>Worth being specific about this, because it's easy to undersell in hindsight once things broke: this wasn't a fragile demo held together with hope. The voice pipeline (USB mic → Deepgram → GPT-4o → Aura TTS → speaker) was confirmed working. Wake-word detection was reliable. Session management, echo-gating, all functioning. And gestures — real, physical robot motion, dispatched live based on GPT-4o's own output via a <code>[G:id]</code> tag system — were working over a custom WebRTC connection to the robot, with built-in gesture IDs (wave, various expressive motions) confirmed executing correctly in front of a real audience.</p>
<p>That's a genuinely complete, working system, not a proof of concept.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="six-days-later-firmware-151">Six days later: firmware 1.5.1<a href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk#six-days-later-firmware-151" class="hash-link" aria-label="Direct link to Six days later: firmware 1.5.1" title="Direct link to Six days later: firmware 1.5.1" translate="no">​</a></h2>
<p>The robot's firmware was updated to 1.5.1 immediately after the demo. What followed was a cascading, multi-system regression:</p>
<ul>
<li class=""><strong>WebRTC connections broke outright.</strong> The <code>/con_notify</code> endpoint began returning a new, BLE-derived device-specific key (<code>data2=3</code>) as part of the handshake — undocumented at the time, and any client not deriving and presenting that key was rejected.</li>
<li class=""><strong>The internal speaker died.</strong></li>
<li class=""><strong>Motor alarms appeared</strong> that hadn't been present before.</li>
<li class=""><strong>Custom (taught) gestures were separately, silently broken</strong> — though this turned out to be a distinct, pre-existing problem tangled up in the same crisis: custom gestures dispatched via <code>api_id 7112</code> were returning success codes with no physical motion at all, a <a class="" href="https://robotomy.ai/docs/log/webrtc-gestures/fsm-gating-custom-gestures">finite-state-machine gating issue</a> that had nothing to do with the firmware regression, but surfaced around the same time and initially looked like part of the same disaster.</li>
</ul>
<p>The honest state of things at that point: a working demo robot had become, within a week, a non-operational one, with three or four separate problems compounding into a single confusing mess.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-community-found-what-vendor-support-hadnt-yet">The community found what vendor support hadn't yet<a href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk#the-community-found-what-vendor-support-hadnt-yet" class="hash-link" aria-label="Direct link to The community found what vendor support hadn't yet" title="Direct link to The community found what vendor support hadn't yet" translate="no">​</a></h2>
<p>The break was reported to <code>unitree_webrtc_connect</code>'s issue tracker on April 11 — <a href="https://github.com/legion1581/unitree_webrtc_connect/issues/53" target="_blank" rel="noopener noreferrer" class="">issue #53</a> — by another affected user hitting the exact same <code>data2=3</code> failure. <strong>legion1581</strong>, the library's maintainer, responded the same day: this was a new key-derivation method, device-specific, and would take real reverse-engineering effort to crack. By the next day, the mechanism was understood in more detail — the key is generated fresh on the G1 at every boot, obtained via BLE, and requires processing on Unitree's own cloud server.</p>
<p>That same issue thread is also where a good deal of this project's own early arm-service reverse-engineering got documented and shared back with the community — the undocumented <code>rt/api/arm/request</code> topic, the <code>api_id 7112</code> custom-gesture-by-name endpoint, and several other G1-specific findings that weren't in Unitree's own docs at the time.</p>
<p>The fix that actually shipped, roughly three weeks later around May 1, was <strong>proper AES-128 key support built into the library itself</strong> — not the simpler pairing-based theory floated in the first days of investigation. The mechanism: a <code>unitree-fetch-aes-key</code> CLI tool pulls the device's own AES-128 key directly from Unitree's cloud (specifying region and device type), which then gets passed into the connection (<code>aes_128_key=</code>, <code>device_type="G1"</code>) alongside a library upgrade (2.0.4 → 2.1.2).</p>
<p>This project confirmed it working end-to-end on May 20 — full handshake, data channel validation, heartbeat, and arm gestures firing cleanly over the data channel, on G1 firmware 1.5.2. From initial break to confirmed working fix: <strong>just over five weeks</strong>, and the real resolution came from a proper library-level implementation, not the earlier, simpler hypothesis about how the key exchange worked.</p>
<p>This is worth pausing on regardless of the exact mechanism: the fix came from a community member's own sustained investigation and implementation work, not a vendor patch. That's not a knock on Unitree's support process — it's a genuine case for staying plugged into the community around a platform like this, rather than just filing a ticket and waiting on an opaque timeline.</p>
<p>Worth being precise about one more thing, since it's easy to conflate: this AES-128 key fix is what actually restored WebRTC connectivity. A separate Unitree firmware update, 1.5.2, fixed a <em>different</em> problem — a conflict between WebRTC and the vendor's own onboard ASR service — documented separately in <a class="" href="https://robotomy.ai/docs/log/webrtc-gestures/webrtc-1.5.1-break-1.5.2-fix">the WebRTC 1.5.1/1.5.2 entry</a>. One was a community fix for a community-discovered break; the other was an unrelated vendor-side regression fix.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-actual-decision-stop-depending-on-webrtc-for-gesture-control">The actual decision: stop depending on WebRTC for gesture control<a href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk#the-actual-decision-stop-depending-on-webrtc-for-gesture-control" class="hash-link" aria-label="Direct link to The actual decision: stop depending on WebRTC for gesture control" title="Direct link to The actual decision: stop depending on WebRTC for gesture control" translate="no">​</a></h2>
<p>This is the real turning point, and it's a decision made deliberately, not a fallback stumbled into. Once legion1581's method restored a working connection and the immediate crisis was survivable, the higher-priority question became: should gesture control keep depending on WebRTC at all, given what had just happened?</p>
<p>The answer was no. <strong>Direct <code>rt/arm_sdk</code> control — joint-level commands over DDS, bypassing WebRTC entirely</strong> — became the planned path forward for gesture dispatch, explicitly named as the highest-priority investigation once the immediate WebRTC crisis was stabilized.</p>
<p>The reasoning tracks with a pattern that shows up elsewhere in this project's decisions too: <strong>a dependency that can be silently broken by a vendor firmware update, with no advance notice and no control over the timeline, is a fragile foundation for anything meant to run reliably in front of real people.</strong> WebRTC wasn't abandoned because it was inherently bad — it worked, genuinely, on April 11. It was deprioritized because the April 17 regression demonstrated a real, uncontrolled failure mode that a DDS-based direct control path doesn't share to the same degree. This is the same category of judgment call as <a class="" href="https://robotomy.ai/docs/log/platform-cuda/containerize-dont-fight-the-wheel">choosing to containerize rather than hand-compile CUDA dependencies</a> — recognizing when a dependency's fragility outweighs its convenience, and deliberately routing around it rather than continuing to patch around the same weak point indefinitely.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-this-leaves-things">Where this leaves things<a href="https://robotomy.ai/blog/webrtc-saga-april-demo-to-armsdk#where-this-leaves-things" class="hash-link" aria-label="Direct link to Where this leaves things" title="Direct link to Where this leaves things" translate="no">​</a></h2>
<p>WebRTC didn't disappear from the stack entirely — it still has a role for vendor-adjacent audio and specific transport needs. But gesture control moved to a path this project actually controls end-to-end, rather than one that could be reshaped without warning by someone else's firmware release schedule. The FSM-gating investigation that got tangled up in this same crisis turned out to have its own independent fix, applicable regardless of which transport carries the command. And the whole episode is a big part of why this project treats "is this dependency something a firmware update outside my control could silently break" as a real design question now, not an afterthought.</p>]]></content:encoded>
            <category>WebRTC</category>
            <category>Firmware</category>
        </item>
    </channel>
</rss>