Skip to content

Commit 54c2f00

Browse files
author
hello-robot
committed
Wake/sleep phrases; Auto-sleep after 30s of no movement; Refactor micLevelGate.ts to create a small audio buffer so the beginning of utterances aren't clipped;
1 parent 5c79061 commit 54c2f00

12 files changed

Lines changed: 826 additions & 164 deletions

File tree

ai-gateway/constants.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,10 @@ export declare const MACRO_MOVE_ACTIONS: readonly [
8080

8181
export declare const VOICE_SPEEDS: readonly ["slow", "medium", "fast"];
8282
export declare const VOICE_SPEED_DEFAULT: "medium";
83+
export declare const VOICE_SLEEP_PHRASE: "bye bye stretch";
84+
export declare const VOICE_WAKE_PHRASE_DISPLAY: "Hello Stretch";
85+
export declare const VOICE_SLEEP_PHRASE_DISPLAY: "Bye bye Stretch";
86+
export declare const VOICE_WAKE_PHRASE_ALT: "hello robot";
87+
export declare const VOICE_SLEEP_PHRASE_ALT: "bye bye robot";
88+
export declare const VOICE_WAKE_PHRASE_ALT_DISPLAY: "Hello Robot";
89+
export declare const VOICE_SLEEP_PHRASE_ALT_DISPLAY: "Bye bye Robot";

ai-gateway/constants.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,13 @@ const EXECUTE_MACRO = "execute_macro";
6262
const VOICE_MACRO_NAMES = ["center_wrist", "stow_wrist"];
6363

6464
/** All voice Realtime tools registered in the session payload and executed on the client. */
65-
const VOICE_TOOLS = [EXECUTE_BASE_MOVE, EXECUTE_JOINT_MOVE, STOP_MOTION, REPEAT_BASE_MOVE, EXECUTE_MACRO];
65+
const VOICE_TOOLS = [
66+
EXECUTE_BASE_MOVE,
67+
EXECUTE_JOINT_MOVE,
68+
STOP_MOTION,
69+
REPEAT_BASE_MOVE,
70+
EXECUTE_MACRO,
71+
];
6672

6773
// ── Base move action enums ────────────────────────────────────────────────────
6874

@@ -132,6 +138,31 @@ const VOICE_SPEEDS = ["slow", "medium", "fast"];
132138
/** Server schema default when the model omits speed. */
133139
const VOICE_SPEED_DEFAULT = "medium";
134140

141+
/** Normalized wake phrase (substring match in client transcripts). */
142+
const VOICE_WAKE_PHRASE = "hello stretch";
143+
144+
/** Normalized sleep phrase (substring match in client transcripts). */
145+
const VOICE_SLEEP_PHRASE = "bye bye stretch";
146+
147+
/** User-facing wake phrase label (UI, errors, model instructions). */
148+
const VOICE_WAKE_PHRASE_DISPLAY = "Hello Stretch";
149+
150+
/** User-facing sleep phrase label (UI, errors, model instructions). */
151+
const VOICE_SLEEP_PHRASE_DISPLAY = "Bye bye Stretch";
152+
153+
/** Alternate normalized wake phrase (substring match in client transcripts). */
154+
const VOICE_WAKE_PHRASE_ALT = "hello robot";
155+
156+
/** Alternate normalized sleep phrase (substring match in client transcripts). */
157+
const VOICE_SLEEP_PHRASE_ALT = "bye bye robot";
158+
159+
/** User-facing alternate wake phrase label. */
160+
const VOICE_WAKE_PHRASE_ALT_DISPLAY = "Hello Robot";
161+
162+
/** User-facing alternate sleep phrase label. */
163+
const VOICE_SLEEP_PHRASE_ALT_DISPLAY = "Bye bye Robot";
164+
165+
135166
module.exports = {
136167
VOICE_DURATION_MS_MIN,
137168
VOICE_DURATION_MS_MAX,
@@ -150,6 +181,7 @@ module.exports = {
150181
REPEAT_BASE_MOVE,
151182
EXECUTE_MACRO,
152183
VOICE_MACRO_NAMES,
184+
153185
VOICE_TOOLS,
154186
BASE_TRANSLATE_ACTIONS,
155187
BASE_ROTATE_ACTIONS,
@@ -160,4 +192,12 @@ module.exports = {
160192
JOINT_MOVE_ACTIONS,
161193
VOICE_SPEEDS,
162194
VOICE_SPEED_DEFAULT,
195+
VOICE_WAKE_PHRASE,
196+
VOICE_SLEEP_PHRASE,
197+
VOICE_WAKE_PHRASE_DISPLAY,
198+
VOICE_SLEEP_PHRASE_DISPLAY,
199+
VOICE_WAKE_PHRASE_ALT,
200+
VOICE_SLEEP_PHRASE_ALT,
201+
VOICE_WAKE_PHRASE_ALT_DISPLAY,
202+
VOICE_SLEEP_PHRASE_ALT_DISPLAY,
163203
};

ai-gateway/openai-realtime-api.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ const {
3535
JOINT_DISTANCE_RAD_MAX,
3636
EXECUTE_MACRO,
3737
VOICE_MACRO_NAMES,
38+
VOICE_WAKE_PHRASE_DISPLAY,
39+
VOICE_SLEEP_PHRASE_DISPLAY,
40+
VOICE_WAKE_PHRASE_ALT_DISPLAY,
41+
VOICE_SLEEP_PHRASE_ALT_DISPLAY,
3842
} = require("./constants");
3943

4044
/**
@@ -68,7 +72,10 @@ function buildRealtimeVoiceSessionPayload() {
6872
type: "realtime",
6973
model: "gpt-realtime-2",
7074
instructions: [
71-
"Don't speak to the user",
75+
"Do not respond to the user. Do not speak to the user. Only call tools as instructed.",
76+
// Wake/sleep phrases — do not tool-call
77+
`When the user says only ${VOICE_WAKE_PHRASE_DISPLAY}, ${VOICE_WAKE_PHRASE_ALT_DISPLAY}, or similar wake greetings, do NOT call any tool.`,
78+
`When the user says only ${VOICE_SLEEP_PHRASE_DISPLAY}, ${VOICE_SLEEP_PHRASE_ALT_DISPLAY}, or similar farewells, do NOT call any tool.`,
7279
// ── Base move harness ────────────────────────────────────────────────────
7380
"The user speaks imprecisely. Infer exactly ONE motion command per turn.",
7481
`For BASE movement (forward, backward, strafe, rotate), call tool ${EXECUTE_BASE_MOVE}.`,
@@ -233,12 +240,24 @@ function buildRealtimeVoiceSessionPayload() {
233240
tool_choice: "auto",
234241
audio: {
235242
input: {
243+
244+
transcription: {
245+
model: "gpt-4o-mini-transcribe",
246+
language: "en",
247+
},
248+
/** Docs: https://developers.openai.com/api/docs/guides/realtime-vad#server-vad */
236249
turn_detection: {
237250
type: "server_vad",
238-
threshold: 0.7,
251+
// How loud and clear incoming audio must be
252+
// before the server counts it as speech
253+
threshold: 0.5,
254+
// When VAD detects speech, OpenAI includes 300ms
255+
// of audio from before the trigger point in that turn
239256
prefix_padding_ms: 300,
240257
silence_duration_ms: 300,
241258
create_response: true,
259+
// If user starts speaking while it will
260+
// interrupt the prior in-flight response
242261
interrupt_response: true,
243262
},
244263
},

src/pages/operator/tsx/function_providers/ButtonFunctionProvider.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
2-
JOINT_VELOCITIES,
32
JOINT_INCREMENTS,
3+
JOINT_VELOCITIES,
44
ValidJoints,
55
ValidJointStateDict,
66
} from "shared/util";
@@ -240,9 +240,6 @@ export class ButtonFunctionProvider extends FunctionProvider {
240240
if (!FunctionProvider.remoteRobot) {
241241
return false;
242242
}
243-
if (this.timedVoiceMoveActive) {
244-
return false;
245-
}
246243

247244
const clampedMs = clampDurationMs(durationMs);
248245
this.stopCurrentAction(true);

src/pages/operator/tsx/function_providers/FunctionProvider.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { RemoteRobot } from "shared/remoterobot";
21
import { VelocityCommand } from "shared/commands";
2+
import { RemoteRobot } from "shared/remoterobot";
33
import { RobotPose, ValidJoints } from "shared/util";
4+
import { PilotButtonPads } from "../static_components/PilotControlsToggle";
45
import {
56
ActionModeType,
67
PilotButtonPadType,
78
} from "../utils/component_definitions";
89
import { clampDurationMs } from "../voice/constants";
910
import { ButtonPadButton } from "./ButtonFunctionProvider";
10-
import { PilotButtonPads } from "../static_components/PilotControlsToggle";
1111

1212
const x = PilotButtonPads;
1313

@@ -63,6 +63,15 @@ export abstract class FunctionProvider {
6363
return FunctionProvider.remoteRobot !== undefined;
6464
}
6565

66+
/** True while a timed voice move or continuous velocity heartbeat is active. */
67+
public isMotionActive(): boolean {
68+
return (
69+
this.timedVoiceMoveActive ||
70+
this.velocityExecutionHeartbeat !== undefined ||
71+
this.activeVelocityAction !== undefined
72+
);
73+
}
74+
6675
/**
6776
* Move the robot to an absolute pose (e.g. a voice macro).
6877
* Stops any ongoing velocity or timed move, then sends a setRobotPose command.
@@ -149,7 +158,7 @@ export abstract class FunctionProvider {
149158
* @param jointName the joint to actuate
150159
* @param velocity signed velocity in joint units/s (m/s for linear, rad/s for rotary)
151160
* @param durationMs how long to apply velocity (clamped internally)
152-
* @returns false if a timed move is already active or no robot attached
161+
* @returns false if no robot attached
153162
*/
154163
public timedJointMove(
155164
jointName: ValidJoints,
@@ -159,9 +168,6 @@ export abstract class FunctionProvider {
159168
if (!FunctionProvider.remoteRobot) {
160169
return false;
161170
}
162-
if (this.timedVoiceMoveActive) {
163-
return false;
164-
}
165171

166172
const clampedMs = clampDurationMs(durationMs);
167173

src/pages/operator/tsx/static_components/VoiceCommandAssistant.tsx

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,39 @@
33
* sits at the top of the screen. Very WIP.
44
*/
55

6+
import "operator/css/VoiceCommandAssistant.css";
67
import React, {
78
useCallback,
89
useEffect,
910
useRef,
1011
useState,
1112
} from "react";
13+
import { getOperatorVoiceSessionToken } from "shared/operatorVoiceSession";
1214
import { buttonFunctionProvider } from "..";
15+
import Ellipsis from "../basic_components/Ellipsis";
1316
import { FunctionProvider } from "../function_providers/FunctionProvider";
14-
import { ActionModeType } from "../utils/component_definitions";
17+
import type { AddToastFn } from "../layout_components/Toasts";
1518
import { velocityScaleForVoiceSpeed } from "../utils/action-speed-scale";
16-
import {
17-
connectOpenAIRealtimeVoice,
18-
type ActiveRealtimeVoiceSession,
19-
} from "../voice/realtimeSession";
20-
import { setVoiceMoveExecutionContext } from "../voice/executeBaseMove";
21-
import { getOperatorVoiceSessionToken } from "shared/operatorVoiceSession";
19+
import { ActionModeType } from "../utils/component_definitions";
2220
import {
2321
isVoiceToolLogLine,
2422
VOICE_MIC_RMS_THRESHOLD,
25-
type VoiceSpeed,
23+
VOICE_WAKE_PHRASE_ALT_DISPLAY,
24+
VOICE_WAKE_PHRASE_DISPLAY,
2625
type VoiceMoveExecutionMode,
26+
type VoiceSpeed,
2727
} from "../voice/constants";
28+
import { setVoiceMoveExecutionContext } from "../voice/executeBaseMove";
29+
import {
30+
connectOpenAIRealtimeVoice,
31+
type ActiveRealtimeVoiceSession,
32+
type VoiceListeningState,
33+
} from "../voice/realtimeSession";
2834
import { voiceMoveFeedbackToToast, type VoiceMoveFeedback } from "../voice/voiceMoveFeedback";
2935
import {
3036
AccessibleRadioGroup,
3137
type AccessibleRadioOption,
3238
} from "./AccessibleRadioGroup";
33-
import Ellipsis from "../basic_components/Ellipsis";
34-
import type { AddToastFn } from "../layout_components/Toasts";
35-
import "operator/css/VoiceCommandAssistant.css";
3639

3740
/** Explicit check to make sure operator is using
3841
* LAN/ngrok (LocalStorage + socket.io) and not cloud (Firebase) */
@@ -86,6 +89,8 @@ export const VoiceCommandAssistant = ({
8689
);
8790
const [voiceMoveExecutionMode, voiceMoveExecutionModeSet] =
8891
useState<VoiceMoveExecutionMode>("direct");
92+
const [listeningState, listeningStateSet] =
93+
useState<VoiceListeningState>("asleep");
8994

9095
const executionModeLocked =
9196
phase === "connecting" || phase === "live";
@@ -109,6 +114,7 @@ export const VoiceCommandAssistant = ({
109114
statusLineSet("Connect");
110115
micLevelSet(0);
111116
micGateOpenSet(false);
117+
listeningStateSet("asleep");
112118
}, []);
113119

114120
useEffect(() => {
@@ -151,12 +157,14 @@ export const VoiceCommandAssistant = ({
151157
onVoiceSpeedChange,
152158
onVoicePressAndHoldRequired,
153159
onVoiceMoveFeedback,
160+
onListeningState: listeningStateSet,
154161
onLog: (lg) => {
155-
if (isVoiceToolLogLine(lg)) {
156-
console.log(
157-
"[VoiceCommandAssistant] tool trace:",
158-
lg.slice(0, 240),
159-
);
162+
if (
163+
lg.includes("[WakeSleep]") ||
164+
lg.includes("user transcript") ||
165+
isVoiceToolLogLine(lg)
166+
) {
167+
console.log("[VoiceCommandAssistant]", lg.slice(0, 240));
160168
}
161169
},
162170
onMicLevel: (level, gateOpen) => {
@@ -192,11 +200,11 @@ export const VoiceCommandAssistant = ({
192200
const micGateHint =
193201
phase !== "live"
194202
? ""
195-
: micGateOpen
196-
? "Listening..."
197-
: micLevel > 0.001
198-
? "Microphone gate closed"
199-
: "Ready";
203+
: listeningState === "asleep"
204+
? `Asleep — say "${VOICE_WAKE_PHRASE_DISPLAY}" or "${VOICE_WAKE_PHRASE_ALT_DISPLAY}" to wake (or tap Wake)`
205+
: micGateOpen
206+
? "Listening..."
207+
: "Awake — speak your command";
200208

201209
const primaryButtonLabel =
202210
phase === "live"
@@ -359,7 +367,9 @@ export const VoiceCommandAssistant = ({
359367
transform: `scaleX(${meterFill})`,
360368
backgroundColor: micGateOpen
361369
? "hsl(184deg 100% 50%)"
362-
: "hsla(184, 100%, 50%, 0.45)",
370+
: listeningState === "asleep"
371+
? "hsla(184, 100%, 50%, 0.2)"
372+
: "hsla(184, 100%, 50%, 0.45)",
363373
transition: "transform 0.05s linear",
364374
willChange: "transform",
365375
}}
@@ -383,13 +393,28 @@ export const VoiceCommandAssistant = ({
383393
fontWeight: 400,
384394
fontSize: "0.82em",
385395
margin: 0,
386-
color: micGateOpen
387-
? "hsl(184deg 100% 50%)"
388-
: "hsl(184deg 60% 87%)",
396+
color:
397+
listeningState === "awake" && micGateOpen
398+
? "hsl(184deg 100% 50%)"
399+
: "hsl(184deg 60% 87%)",
389400
}}
390401
>
391402
{micGateHint}
392403
</p>
404+
{listeningState === "asleep" ? (
405+
<button
406+
type="button"
407+
style={{
408+
...styleButton,
409+
height: 36,
410+
fontSize: "0.9em",
411+
marginTop: 4,
412+
}}
413+
onClick={() => sessionRef.current?.wake()}
414+
>
415+
Wake
416+
</button>
417+
) : null}
393418
</div>
394419
) : null}
395420
</div>

0 commit comments

Comments
 (0)