One repo covering two tracks from the AI Infra Summit "Choose Your Path" email:
- Online track (Intel -- Bimanual VLA Manipulation with Multi-Modal Reasoning): two simulated arms set a dinner table (plate, fork, knife, cup), simulation-only, no hardware.
- Bonus track (Best Use of Speechmatics): the same task driven by a spoken instruction instead of typed text.
They share one code path on purpose: a Speechmatics transcript and a typed instruction both
just become the instruction string handed to the same planner. There is no separate voice
demo bolted on the side.
mic / WAV --Speechmatics--> transcript --\
>-- plan_from_instruction() --> [PlanStep, ...]
typed text ------------------------------- / |
v
control/primitives.py (pick/place)
|
v
control/ik.py (damped least-squares)
|
v
sim/env.py (mujoco, ALOHA bimanual rig)
sim/env.py--DinnerTableEnv, a thin wrapper directly on the officialmujocoPython bindings (not robosuite -- see "Why not robosuite" below). Loadsassets/mujoco_menagerie/aloha/dinner_table.xml: Google DeepMind's ALOHA two-arm rig (2x 6-DOF ViperX 300 arms + parallel grippers, position-controlled) plus four tableware bodies we added (plate, fork, knife, cup).control/ik.py-- position-only damped least-squares IK against each arm's{side}/grippersite. No orientation term (see Known limitations).control/primitives.py--pick(env, ctrl, side, object_name)andplace(env, ctrl, side, target_xyz), built entirely on the IK solver plus the arms' native position actuators. This is the only thing the planner's output ever drives -- it never sees a joint angle.planner/vla_planner.py-- the "VLA" reasoning layer. Sends the overhead camera render + the instruction to a vision-capable LLM (Groq'sqwen/qwen3.6-27bby default -- confirmed live againstconsole.groq.com/docs/visionas one of exactly two multimodal models Groq currently offers; any other OpenAI-compatible provider works by settingLLM_BASE_URL/LLM_API_KEY/LLM_MODELinstead, no code change) with a system prompt describing the two arms and the four objects, gets back a structured JSON plan. Training real VLA weights in a hackathon isn't realistic; a multimodal LLM doing the "what to do" reasoning whilecontrol/does deterministic "how to move it" is the scoped-realistic version of the same idea.voice/speechmatics_client.py-- real-time transcription over Speechmatics' WebSocket protocol. Verified end to end with real spoken audio (see "Full voice-to-action pipeline" below) -- connects, authenticates, transcribes correctly, and feeds a real VLA plan and grasp execution.demo.py-- the single entrypoint,--input textor--input voice.
uv sync
cp .env.example .env # fill in GROQ_API_KEY and SPEECHMATICS_API_KEYThe ALOHA model assets (assets/mujoco_menagerie/aloha/, ~20MB) are already vendored in
this repo (sparse-checked-out from google-deepmind/mujoco_menagerie, Apache-2.0) -- no
extra download step.
uv run python -m aisummit.demo --input text --instruction "pick up the cup and move it to the left side of the table"Writes outputs/before.png and outputs/after.png.
Demo scope, decided after the exhaustive fork/knife investigation below: the reliable,
recommended demo instruction manipulates the cup (and, by extension, the plate, both
radially symmetric). The VLA planner still correctly perceives and reasons about all four
objects -- fork and knife included -- from the same scene; acquire_object correctly detects
and reports fork/knife grasp infeasibility (success=False, with the real diagnosed reason)
rather than silently failing or faking a result. This is a genuine, documented engineering
finding, not a hidden gap -- see "Alternative contact strategies" below for the full evidence
trail, including the final position + rotation sweep that closes out the investigation.
uv run python -m aisummit.demo --input voice --wav-file sample.wav # 16kHz mono PCM16 WAVuv run pytest tests/ -vtests/test_pick_and_place.py is a real, physics-simulated pick-and-place (no mocks): resets
the env, grasps the cup with the right arm, moves it, places it, and asserts the final position
and that it was actually lifted off the table mid-motion. Passing as of this commit.
robosuite is the obvious first choice for bimanual manipulation, but it's currently broken on native Windows in a way that isn't a config issue:
- It ships expecting a
mujoco.dllcopy inside its own package that pip doesn't actually install (needs a manual copy from themujocopackage's own install location). - Once that's fixed, its offscreen renderer hardcodes
MUJOCO_GL=egl(Linux/GPU-server-only) unless a private macro file disables GPU rendering -- another manual fix. - Even after both of those, its
OperationalSpaceControllercallsmujoco.mj_fullM(..., data.qM), andqMwas removed fromMjDatain mujoco 3.11+ (confirmed by bisecting versions 3.1.6 through 3.13.0: present through 3.10.0, gone from 3.11.0 on). Pinning mujoco back to 3.10.0 to keepqMaround then hits a second, deepermj_fullMargument binding mismatch that wasn't worth chasing further.
Given a one-week window, we dropped robosuite and built directly on the official mujoco
bindings plus DeepMind's own ALOHA model from mujoco_menagerie -- verified working
end-to-end on this machine (model loads, renders, and the pick-and-place test passes).
The IK-is-position-only limitation above was real and has been substantially addressed --
control/ik.py's solve_ik now takes an optional target_quat (omit it and it's the exact
same position-only solve it always was; every existing caller is unaffected), and a new
grasping/ package sits above it doing real geometric reasoning instead of "find XYZ and
grab":
estimate_object_geometry() -> generate_candidates() -> evaluate_candidate() (scored,
(geometry.py, reads (candidates.py, sweeps ranked, collision- and
MuJoCo's own body/geom the arm's own wrist_rotate IK-filtered; scoring.py)
pose -- see its docstring joint, measures the real
for the perception finger-to-finger line via
scoping trade-off) forward kinematics)
|
v
grasp_object() executes the best-scoring
candidate, verifies the lift physically,
and retries the next-best candidate (never
repeating one) on failure (planner.py)
- Radially symmetric objects (cup, plate) take the exact original
pick()path, unchanged --grasp_objectchecksgeometry.is_elongatedfirst and only runs the new machinery for the fork/knife case.tests/test_grasping.py::test_radial_object_uses_unmodified_pick_pathasserts the candidate generator never even runs for the cup. assets/mujoco_menagerie/aloha/dinner_table.xmlnow authors a{object}/handlesite (and a{object}/unsafeblade-region site for the knife) on the fork and knife bodies -- ground-truth grasp-region geometry standing in for a real segmentation+PCA stage.grasping/collision.pyposes a candidate's solved joint angles on a scratchMjData(no dynamics stepped) and checks MuJoCo's own contact detection against both other tableware objects and the table/floor.grasping/debug.pygives a structured trace of every candidate (accepted/rejected and why) plusdraw_grasp_overlay(), which projects the object's principal axis, handle, unsafe region, and selected grasp point onto the overhead camera image using MuJoCo's own camera intrinsics -- verified working (see the trace/overlay evidence in the implementation writeup).grasping/scoring.py::GraspWeightsholds every scoring weight as one configurable dataclass rather than literals scattered through the code.grasping/planner.py::stabilize_with_other_armis a real, tested, opt-in bimanual capability (the other arm lightly touches the object's far end while the grasp arm works) -- not force-enabled, since the current object set's widths don't naturally cross the low-stability trigger.
Six real bugs were found and fixed while getting this reliable, each root-caused via direct physics inspection (contact lists, joint tracking error, forward-kinematics offsets), not guessed:
collision.pyoriginally never checked collisions against the table/floor itself, only other tableware.- The original two-waypoint "approach then descend" pattern (copied from
pick()) measurably hurt IK convergence for twisted, non-top-down orientations (a kinematic elbow-flip effect) --pick_oriented/place_orientednow move directly to the grasp pose in one stage. - The named
{side}/grippersite IK targets is not the same point as the actual finger-closing midpoint -- a fixed ~1.4cm mechanical offset (calibrated empirically, confirmed constant across a 120-degree wrist sweep and identical for both arms; seecontrol/ik.py::GRIPPER_SITE_TO_FINGER_MIDPOINT_OFFSET). Invisible for the cup (3cm radius swallows a 1.4cm error) but larger than the fork's entire half-width -- the single biggest reason early grasp attempts silently missed.finger_target_to_site_target()converts a true finger-midpoint target into the site targetsolve_ikneeds. - Cold-start IK can fail to converge for a far, heavily-twisted target that a warm-started
solve reaches easily. Found via a real false positive: a candidate that scored as
collision-free and well-aligned was actually nowhere near its intended target, because the
check that "confirmed" it only verified collision-freedom, not position error.
solve_ikgained an optionalseed_angles(additive, same pattern astarget_quat);generate_candidatesnow stores each candidate's own wrist-swept seed configuration and every downstream solve (scoring, execution) warm-starts from it. - Authored object heights aren't resting-contact height -- the fork/knife/plate start
~1.6cm above the table and settle under gravity within ~50 physics steps; geometry
estimated immediately after
reset()(before any arm motion) was stale by the time the arm actually got there. Fixed withDinnerTableEnv.settle(), called only from the elongated-object grasp path -- not fromreset()itself, because doing so shifted the cup regression test's exact numbers enough to expose a separate, pre-existing fragility inplace()'s release/retreat step. Keepingreset()untouched for the radial path and settling only where nothing previously depended on the un-settled state is what let this land without breaking the cup. - A grasp-height clearance margin (added to give the gripper mechanism vertical clearance from the table at wide wrist angles) had been implicitly tuned against the stale fork height from bug 5 -- once that was fixed, the same clearance constant needed retuning against the real, lower height.
The previous iteration's "fastest next step" (penalize finger-height mismatch in scoring) was implemented and gave real, measured improvement, but did not close the loop:
geometry.py::ObjectGeometry.thickness-- the object's shortest half-extent (for a box, the flat dimension; for a cylinder, falls back to radius since no elongated cylinder exists in the current object set to calibrate against).scoring.py--evaluate_candidatenow measuresfinger_left_z/finger_right_zdirectly from the same solved-angle forward kinematics every other metric already uses, and scoresfinger_height_mismatchwith the same clipped-linear shape already used forconfig_distance_score/workspace_margin(no new scoring idiom introduced), tolerance scaled to2*thickness + 5mmso a thin object is judged strictly and a thick one leniently.GraspWeights.finger_height_symmetry(default 2.0, same order asorientation_alignment) makes the trade-off configurable.candidates.py-- the wrist sweep went from 15-degree to 5-degree steps. This was tested as unnecessary at first (a 15-degree sample,wrist=105deg, appeared to have both good alignment and near-zero mismatch) -- then found to be a measurement artifact: that "candidate" was actually a badly non-converged solve sitting nowhere near the target (0.14m position error, 1.5 rad orientation error); its "good" mismatch was meaningless because the whole pose was wrong. Checkingik_okbefore trusting any derived metric is what caught this. The finer sweep was then genuinely necessary to find real, converged options near the good-alignment region.
Measured result: at 5-degree resolution, checked across a height sweep (1.5cm-6cm
clearance) and against both arms, no candidate exists for the fork's current position that
is simultaneously well-aligned (closing axis near-perpendicular to the fork) and vertically
level (fingers within a few mm of each other in height) -- the practical floor for
well-aligned candidates is a consistent ~9mm finger-height mismatch, regardless of grasp
height or which arm reaches for it. That's larger than the fork's entire 8mm thickness.
Scoring correctly identifies and selects the best genuinely-available trade-off (wrist=90deg,
alignment 0.91, mismatch 9.3mm, score 0.734 -- versus 0.846 before this fix, appropriately
lower since the mismatch is now honestly penalized) but that trade-off still isn't good enough
to grasp an object this thin.
Root cause, confirmed by direct measurement (not inferred): at the selected candidate,
before closing, left_finger sits at z=20.8mm and right_finger at z=61.1mm, while the fork's
surface spans roughly z=[-1.9mm, 6.1mm]. Only the left finger is anywhere near the object.
After closing, contact logs confirm exactly that: 2 contacts recorded between the left finger
and the fork, 0 between the right finger and the fork, for the entire hold+lift sequence -- the
fork is grazed by one finger and never captured. This is failure mode A: fingers never
contact both sides (see the debugging checklist this iteration worked through), not a slip,
not an orientation surprise, not a friction/force issue -- a fixed ~5.5cm gap between the two
fingers' heights that no amount of retry or re-scoring within the current candidate space
closes. The cup and plate grasp reliably throughout all of this (verified after every change,
including the finer wrist sweep and both scoring changes).
Next question tested: does moving the grasp POINT (not just the wrist orientation) around the fork open a feasible bilateral grasp? Answered experimentally, not assumed.
geometry.py::transverse_axis_h()-- unit horizontal vector perpendicular to the object's principal axis, reusing the existing axis abstraction rather than inventing a second one.candidates.py--generate_candidatesnow varies the grasp point across a small star pattern (each offset axis independently around the base point, not a full grid): 3 longitudinal offsets (unchanged) + 2 new transverse offsets = 5 spatial points, each still swept across the full 37-angle wrist range (185 candidates total, not the "huge brute-force grid" the brief explicitly ruled out). World-frame X/Y offsets aren't a separate dimension: the fork/knife have zero rotation, so their principal/transverse axes already ARE world Y/X -- a separate world-frame sweep would just repeat the same numbers under different labels. The mechanism itself is general (object-relative axes), not fork-specific.scoring.py-- feasibility is now an explicit, separate classification from score (GraspMetrics.feasible/infeasible_reason), not just a low weighted number. A new_finger_reaches_object()check catches something the mismatch-only metric structurally cannot: two fingers can be perfectly symmetric with each other while BOTH floating equally far from the object.evaluate_candidatenow checks, in order,ik_ok->collision_valid-> each finger individually withinthickness + 5mmof the object's own height -> mismatch within tolerance -- any failure makes the candidate infeasible outright, regardless of how high its alignment score is.planner.py--grasp_object's filtering loop now gates onmetrics.feasibledirectly (folding what used to be two separatecollision_valid/ik_okchecks into one, richer classification) instead of scoring a physically-impossible candidate down and hoping it loses.debug.py-- newGraspDebugTrace.spatial_search_summary(): candidates evaluated, IK-converged, geometrically feasible, and the best of each of the feasible/infeasible groups.
This stricter check surfaced a second, independent problem, on top of the wrist-tilt
coupling from the previous iteration: _GRASP_HEIGHT_CLEARANCE (added earlier so the gripper
mechanism clears the table at wide wrist angles) is applied to the literal close point, not
just an approach waypoint -- pick_oriented is deliberately single-stage (see its docstring:
a two-waypoint version caused a worse kinematic elbow-flip), so there is no separate descent
back down to the object's real height before closing. Measured directly: the nominal target
sits ~30mm above the fork's actual surface, for every candidate, regardless of wrist angle or
spatial offset. Lowering the clearance to compensate was also tried and measured: at any
clearance low enough to bring the fingers within reach, the well-aligned wrist angles go back
to penetrating the table -- the exact problem the clearance was added to fix in the first
place. This is a genuine height-vs-clearance conflict, independent of horizontal position.
Experimental answer to the assigned question: across all 5 spatial offsets, swept against
6 clearance heights (12mm-30mm) and a fine wrist sweep (50-130 degrees in 5-degree steps) --
30 x 17 = 510 combinations per spatial point, 2,550 total -- zero candidates are simultaneously
collision-free, within IK tolerance, well-aligned (>0.5), and within finger-reach of the fork.
Moving the grasp point horizontally does not open a feasible bilateral grasp window at the
fork's current pose. The production search (default clearance, no manual override) reports
the same result cleanly: candidates evaluated: 185, IK-converged: 94 (left) / 185 (right), geometrically feasible: 0 for both arms, best infeasible candidate align=0.97, mismatch=9.3mm, reason="neither finger reaches the object".
No physical APPROACH->CLOSE->HOLD->LIFT run was performed this iteration: the brief is explicit that faking a success when no feasible candidate exists is worse than reporting the negative result, and every avenue this pipeline currently searches (wrist orientation, grasp height, horizontal position, both arms) converges on the same conclusion.
Next geometric intervention, implemented and measured in the following iteration: decouple "approach clearance for table avoidance" from "final close height" via a controlled translation-only final descent.
Implemented exactly the intervention flagged above. pick_oriented gains an optional
final_target_xyz: after the existing single-stage approach (position + orientation, unchanged),
a SECOND move -- same target_quat, warm-started from the arm's own just-settled joint angles
(_current_arm_angles, not the original seed_angles) rather than a fresh orientation search --
translates down to the object's real height before closing. GraspCandidate gained a matching
final_target_position (same horizontal point as target_position, at geometry.grasp_region's
real height instead of the clearance-elevated one), computed once in generate_candidates
alongside the existing approach point. _solve_matching_execution (planner.py) mirrors the same
two-stage solve so scoring stays predictive of what execution does -- evaluate_candidate's
position error and workspace-margin checks now compare against final_target_position when set,
since solved_angles represents the post-descent pose, not the approach pose.
Result, measured directly: the descent works exactly as intended -- it eliminates the reach failure entirely. Every rejection reason for both fork and knife, on both arms, changed from "neither finger reaches the object" to "arm would penetrate the table/floor". That's real progress: the pipeline no longer confuses "too high to reach" with "genuinely can't grasp this," and the diagnostic is now precise about which of the two is actually happening. What it did not do is produce a feasible grasp -- the pre-existing collision-vs-alignment conflict (well-aligned wrist angles push part of the arm into the table) was never about height offset, and remains:
fork / left: 185 evaluated, 75 IK-converged, 0 feasible -- best: align=0.80, mismatch=9.2mm, penetration=-21.2mm
fork / right: 185 evaluated, 12 IK-converged, 0 feasible -- best: align=0.00, mismatch=0.0mm, penetration=-5.7mm
knife / left: 185 evaluated, 100 IK-converged, 0 feasible -- best: align=0.00, mismatch=0.0mm, penetration=-12.5mm
knife / right: 185 evaluated, 10 IK-converged, 0 feasible -- best: align=0.80 (wrist 175deg for right arm's chain), mismatch=0.8mm, penetration=-21.1mm
Every entry with meaningful alignment (>0.5) still collides with the table by 2-2.1cm -- consistent with the earlier finding that raising or lowering the target height only trades reach failure for collision failure, never resolves both together, for this arm's kinematics at this grasp position. The descent was the right fix for the problem it targeted (height offset); it was never expected to fix a different, already-identified problem (the collision/alignment coupling itself), and it didn't. Both problems being cleanly separated now (rather than one masking the other) is the concrete gain from this iteration. No physical grasp attempt was run for the same reason as before: no candidate is feasible to attempt.
Tests: 20/20 passing. Two adjustments were needed to keep pre-existing tests meaningful under the
now-more-accurate diagnostic (not weakened): a shared test helper (_solved_angles) was updated
to delegate to the real _solve_matching_execution instead of reimplementing a since-outdated
single-stage version, and the knife handle-preference test dropped its collision_valid
requirement (now uniformly false for every converged knife candidate, a separate finding from
what that test checks) in favor of ik_ok alone, plus an added env.settle() call it had been
missing.
Correction (root cause, verified by direct inspection): every write-up through this point
described the colliding body as "the gripper body" (implying gripper_base, the mounting
bracket behind the fingers). That was wrong. Reading scratch.contact[i] and mapping
geom_bodyid back to body names for the actual best-infeasible candidates -- both the standard
wrist=85deg one and the wrist_angle-varied ones tried afterward -- shows the colliding bodies are
{side}/left_finger_link and {side}/right_finger_link themselves, not gripper_base. This
isn't a mounting-clearance problem; it's that the finger assembly's own physical geometry --
which necessarily must be at the object's height to grasp it -- doesn't fit above the table at
any orientation that closes meaningfully across the fork's width, at this reach position.
Remaining highest-value intervention: the collision-vs-alignment conflict itself.
Rather than continuing to search within the conventional bilateral (symmetric, closing-across- the-width) grasp, this iteration asks a different question: can the fork be acquired through a fundamentally different contact strategy? Two were investigated.
grasping/contact_strategies.py defines four named strategies as data
(CONVENTIONAL_BILATERAL_GRASP, HANDLE_END_ACQUISITION, CONSTRAINED_SIDE_APPROACH,
SCOOPING_BACKSTOP_APPROACH) -- a lookup table, not a class hierarchy, since only two are
investigated and a Protocol/ABC framework for two implementations would be premature. New
planner.py::acquire_object is the "normal grasp is infeasible -> try another contact
strategy" entrypoint: tries the conventional path first, and only escalates on failure.
Handle-end acquisition (implemented and tested): targets the handle's physical end, and --
critically -- searches wrist_angle jointly with wrist_rotate (a bounded 5x13 grid, not the
conventional strategy's wrist_rotate-only sweep), a genuinely different orientation-search
family, not just a relabeled target point. Result: infeasible. Of 65 candidates, 23 converge,
zero are collision-free -- the best converged, well-aligned candidate still penetrates the
table by 2.7cm, the same magnitude found everywhere else. Targeting the literal 1cm-from-tip
point (rather than the handle's center, previously the closest tested) makes no difference
either: identical ~2.3-2.5cm penetration, confirming the constraint isn't about where along
the fork's length the target sits.
Scooping/backstop approach (investigated, not implemented as a motion sequence -- rejected
before building one, backed by a real physical test, not just reasoning): the plate is the only
candidate backstop, and sits 11cm from the fork along the one useful push axis, with its top
surface only ~6mm above the table -- neither fact changes the actual constraint. More directly:
the one orientation already confirmed collision-free (wrist_angle=wrist_rotate=0, a level
approach) was tested for real -- open, approach, close, lift. The fork's position was identical
before approach, after closing, and after lift, to 12 decimal places: the closing axis at that
orientation is parallel to the fork's length, so the gripper closes in the object's own
longitudinal plane and never engages its cross-section. Separately, the ALOHA gripper's ~7.4cm
max opening is smaller than the fork's 18cm length, so an end-to-end pinch spanning the whole
object isn't geometrically possible at any orientation. CONSTRAINED_SIDE_APPROACH is named in
the strategy vocabulary but not separately implemented -- it reduces to the same underlying
constraint handle-end acquisition already tested (finger-link geometry vs. table clearance, not
object-relative positioning).
Contact is not collision (Phase 5 of the brief this implements): collision.py gained
classify_contacts(), purely additive -- check_collision (every existing caller) is
untouched. It labels every arm contact as intended_target, intended_support (a hook for a
future backstop strategy, unused by anything yet), table_penetration, or
unintended_collision, rather than a single pass/fail. verify_retained_during_lift() uses
this for a stricter acquisition check than "did the object move": it requires an actual
finger-object contact at the moment of checking, catching a nudged-but-not-held object that a
position/tracking check alone would misreport as acquired.
Conclusion, stated at the scope this iteration actually tested: no tested conventional bilateral grasp, and no tested handle-end acquisition, at this fork pose satisfies the required kinematic, contact, and table-clearance constraints. Scooping/backstop was evaluated and rejected by direct physical test, not assumed impossible.
Tests: 27/27 passing (20 previous + 7 new, requirements A-G).
The paragraph above left one door open: "repositioning the object... untested." Closed next,
using the exact same real candidate-generation and scoring machinery (generate_candidates +
evaluate_candidate), not new code:
- Distance from the arm: swept the fork's x-position across 7 points, 7cm to 37cm from the left arm's base (current position is ~25cm) -- zero feasible candidates at any distance.
- Object rotation: swept the fork's own orientation on the table through 8 angles spanning the full 180-degree-periodic range (a parallel gripper's alignment condition repeats every 180 degrees) -- zero feasible candidates at any rotation, including angles that would put the "good alignment" wrist angle somewhere the wrist-sweep data showed as collision-free for a different orientation.
15 total distinct configurations, zero successes. This is meaningfully stronger evidence than the earlier, narrower conclusion: the finger-link-vs-table conflict isn't an accident of the fork's specific spot or angle on this table -- it holds across a wide, realistic range of both.
Final scope decision: the demo uses the cup (and plate) as the reliable manipulation
target. Fork and knife stay on the table as real objects the VLA planner correctly perceives
and reasons about, but acquire_object honestly reports them as infeasible rather than faking
a result -- a genuine, well-evidenced engineering finding for this specific hardware
configuration (ALOHA's parallel-jaw gripper, this table height, these object dimensions), not
a gap in the reasoning or planning layers, which work correctly regardless of which objects are
actually graspable.
Previously flagged as entirely unverified. Tested against a real account and API key: the
WebSocket connects to wss://eu2.rt.speechmatics.com/v2, the Authorization: Bearer <key>
header (no JWT exchange) is accepted, and the full message sequence
(StartRecognition -> RecognitionStarted -> AudioAdded -> AddTranscript ->
EndOfStream/EndOfTranscript) matches voice/speechmatics_client.py's implementation exactly
-- confirmed by running the production client against both a raw protocol test and the real
stream_transcripts() function with a synthesized WAV file, no exceptions, clean completion.
Free tier: $100 credit, no card required, 2 concurrent real-time sessions -- sufficient for a
demo.
Tested with real spoken audio (Windows SAPI text-to-speech, resampled to 16kHz mono PCM16),
not just synthesized tones: "Pick up the cup and move it closer to the plate" transcribed
correctly via Speechmatics (streamed back as 5 partial chunks -- "Pick", "up the",
"cup and", "move it", "closer to the plate." -- exactly matching what was spoken once
joined), fed into the Groq-based VLA planner, and executed as a real physics grasp and
placement. Found and fixed three real issues in the process, all from the very first live runs:
demo.py's voice path kept only the LAST transcript chunk, overwriting on each new one (transcript = textin a loop) -- with real speech streaming back in multiple segments (as confirmed above), this would have silently dropped most of any real spoken instruction. Fixed to join every chunk.- Wrong-arm assignment: given the same instruction, the planner assigned the LEFT arm to a
cup that was clearly on the RIGHT side of the frame (confirmed by inspecting
before.png), causing an out-of-reach grasp failure -- a real spatial-reasoning limitation of a fast, free 27B vision model, not a code bug. Mitigated at the prompt level: explicit "choose the arm visually closer to the object in the image" guidance. - Spurious plate-manipulation step: asked to move the cup "closer to the plate," the model sometimes added an extra step to pick up the plate itself -- physically impossible (the plate's 20cm diameter exceeds the gripper's ~7.4cm max opening) and something the prompt never previously ruled out. Fixed with an explicit rule: the plate is a reference landmark, never a pick target, plus "only include steps for objects the instruction actually asks to move."
Re-run 3/3 times after both fixes: correct arm every time, no spurious plate step, real grasp success every time -- confirmed reliable enough for a live demo, not just a lucky one-off.
- Object set is four primitives, not photorealistic tableware meshes -- fine for a reasoning/manipulation demo, not for a visual polish pass.
Anthropic's API isn't free, so the planner switched providers (see planner/vla_planner.py's
docstring) to Groq, which offers a genuine free tier -- confirmed live against
console.groq.com/docs/vision that qwen/qwen3.6-27b and qwen/qwen3.8-27b are currently the
only two vision-capable models Groq hosts. With a real key, this uncovered three real, fixable
issues before the pipeline ran successfully for the first time ever (no outputs/ directory
had existed before this):
- Groq's free tier caps this model's output at 1000 tokens/minute --
max_tokens=1024(an arbitrary default) triggered an immediate 429. Dropped to 800, which the actual expected response (a short JSON plan) never gets close to. - qwen3.6-27b is a reasoning model -- it spent its whole token budget on a
<think>...</think>block and got cut off before ever emitting JSON, confirmed by printing the raw response. Fixed with Qwen3's documented/no_thinkprompt suffix plusextra_body={"reasoning_effort": "none"}in the request -- neither alone was confirmed sufficient, only the combination. - Occasional malformed output -- one live call returned bare comma-separated objects
without the enclosing
[...]the prompt asked for; a separate call returned{"action": "pick"}with no"arm"key at all, while an identical call moments later returned a clean plan. Ordinary small-model run-to-run variance, not a parsing bug -- addressed with a tolerant parser fallback (collects bare{...}objects if no valid array is found) plus a 3-attempt retry, the standard mitigation for this rather than an ever-more-elaborate single-shot parser.
A fourth issue surfaced from the first real full-pipeline run, not the planner in
isolation: asked to move the cup "to the left side of the table," the model (correctly) chose
the right arm to pick it up (closer to the cup), then also assigned that same arm a place
target on the literal opposite edge, 0.72m from its own base -- well beyond move_to's
single-shot reach. The cup moved partway (confirmed: x=0.284 -> 0.065 against a requested
x=-0.25) and stopped, silently, since place() has no success/failure check the way pick()
does. Fixed at the prompt level, not by rewriting placement: the system prompt now states each
arm's own comfortable reach (~0.5m from its base) and instructs the model to place within that,
even when the instruction implies "the other side" -- since the same arm that picks must also
place, there's no handoff in the current plan schema. Re-run after the fix: the model targeted
a placement within the right arm's actual reach, and the cup visibly moved from the far-right
corner toward center-left in the saved outputs/after.png -- a real, achievable plan, not the
literal "far left" the instruction's wording alone would suggest, but a working one.