@agenticros/eyes 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE ADDED
@@ -0,0 +1,2 @@
1
+ @agenticros/eyes includes a procedural R2D2-style synthesizer adapted from
2
+ https://github.com/chrismatthieu/r2d2 (Apache License 2.0). See lib/synth.js.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @agenticros/eyes
2
2
 
3
- Fullscreen robot eyes for an Ubuntu tablet or robot display, driven by ROS 2 `cmd_vel` (`geometry_msgs/Twist`).
3
+ Fullscreen robot eyes for an Ubuntu tablet or robot display, driven by ROS 2 `cmd_vel` (`geometry_msgs/Twist`). Includes procedural R2D2-style chirps (idle + excited on motion).
4
4
 
5
5
  Part of the [AgenticROS](https://github.com/agenticros/agenticros) monorepo. Prefer launching via the CLI:
6
6
 
@@ -8,10 +8,11 @@ Part of the [AgenticROS](https://github.com/agenticros/agenticros) monorepo. Pre
8
8
  agenticros eyes
9
9
  agenticros eyes --no-browser
10
10
  agenticros eyes --no-teleop # gaze only (no WASD publish)
11
+ agenticros eyes --no-sound # mute R2D2 chirps
11
12
  agenticros up real --eyes # start eyes after the real-robot stack
12
13
  ```
13
14
 
14
- See [docs/eyes.md](../../docs/eyes.md) for setup, config, and keyboard teleop.
15
+ See [docs/eyes.md](../../docs/eyes.md) for setup, config, sounds, and keyboard teleop.
15
16
 
16
17
  ## Direct run (development)
17
18
 
@@ -22,4 +23,8 @@ pnpm install # from monorepo root is preferred
22
23
  pnpm start
23
24
  ```
24
25
 
25
- Requires Node 18+, ROS 2, and a graphical display (`DISPLAY`) for kiosk mode.
26
+ Requires Node 18+, ROS 2, a graphical display (`DISPLAY`) for kiosk mode, and `afplay` / `paplay` / `aplay` for sounds.
27
+
28
+ ## License
29
+
30
+ MIT. The synthesizer in `lib/synth.js` is adapted from [r2d2](https://github.com/chrismatthieu/r2d2) under Apache-2.0 (see [NOTICE](NOTICE)).
package/lib/sounds.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Idle + excited R2D2 sound loop for @agenticros/eyes.
3
+ *
4
+ * Plays synthesized WAV via afplay (macOS), paplay, or aplay (Linux).
5
+ * Excited bursts are triggered by active cmd_vel (see exciteFromTwist).
6
+ */
7
+ import { spawn, execFileSync } from "node:child_process";
8
+ import { randomUUID } from "node:crypto";
9
+ import { unlink, writeFile } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { synthesizeExcited, synthesizeRandom } from "./synth.js";
14
+
15
+ const MIN_GAP_MS = Number(process.env.SOUND_MIN_GAP_MS || 300);
16
+ const MAX_GAP_MS = Number(process.env.SOUND_MAX_GAP_MS || 2500);
17
+ const EXCITE_COOLDOWN_MS = Number(process.env.SOUND_EXCITE_COOLDOWN_MS || 600);
18
+ const LINEAR_DEADZONE = Number(process.env.SOUND_LINEAR_DEADZONE || 0.02);
19
+
20
+ /** @type {string | null} */
21
+ let playerBin = null;
22
+ let playerWarned = false;
23
+ let running = false;
24
+ /** @type {import('node:child_process').ChildProcess | null} */
25
+ let currentPlayer = null;
26
+ const tempFiles = new Set();
27
+ let pendingExcitement = 0;
28
+ /** @type {(() => void) | null} */
29
+ let wakeSleep = null;
30
+ let lastExciteAt = 0;
31
+ /** @type {Promise<void> | null} */
32
+ let loopPromise = null;
33
+
34
+ function sleepInterruptible(ms) {
35
+ return new Promise((resolve) => {
36
+ const timer = setTimeout(() => {
37
+ wakeSleep = null;
38
+ resolve();
39
+ }, ms);
40
+ wakeSleep = () => {
41
+ clearTimeout(timer);
42
+ wakeSleep = null;
43
+ resolve();
44
+ };
45
+ });
46
+ }
47
+
48
+ function randomGapMs() {
49
+ return MIN_GAP_MS + Math.random() * (MAX_GAP_MS - MIN_GAP_MS);
50
+ }
51
+
52
+ function findPlayer() {
53
+ if (playerBin !== null) return playerBin || null;
54
+ const candidates = ["afplay", "paplay", "aplay"];
55
+ for (const bin of candidates) {
56
+ try {
57
+ execFileSync("which", [bin], { stdio: "ignore" });
58
+ playerBin = bin;
59
+ return bin;
60
+ } catch {
61
+ // try next
62
+ }
63
+ }
64
+ playerBin = "";
65
+ return null;
66
+ }
67
+
68
+ function stopCurrentPlayback() {
69
+ if (currentPlayer && !currentPlayer.killed) {
70
+ currentPlayer.kill("SIGTERM");
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Queue an excited burst (interrupt idle playback / gap).
76
+ * Rate-limited by SOUND_EXCITE_COOLDOWN_MS.
77
+ */
78
+ export function excite() {
79
+ if (!running) return;
80
+ const now = Date.now();
81
+ if (now - lastExciteAt < EXCITE_COOLDOWN_MS) return;
82
+ lastExciteAt = now;
83
+ pendingExcitement += 1;
84
+ stopCurrentPlayback();
85
+ if (wakeSleep) wakeSleep();
86
+ }
87
+
88
+ /**
89
+ * Excite when Twist has meaningful linear.x or angular.z.
90
+ * @param {object} msg geometry_msgs/Twist-like
91
+ * @param {number} angularDeadzone
92
+ */
93
+ export function exciteFromTwist(msg, angularDeadzone = 0.05) {
94
+ const lx = Math.abs(msg?.linear?.x ?? 0);
95
+ const az = Math.abs(msg?.angular?.z ?? 0);
96
+ if (lx < LINEAR_DEADZONE && az < angularDeadzone) return;
97
+ excite();
98
+ }
99
+
100
+ async function playWav(wavPath) {
101
+ const bin = findPlayer();
102
+ if (!bin) {
103
+ if (!playerWarned) {
104
+ playerWarned = true;
105
+ console.warn(
106
+ "No audio player found (afplay / paplay / aplay). R2D2 sounds disabled.",
107
+ );
108
+ }
109
+ return;
110
+ }
111
+
112
+ const args = bin === "aplay" ? ["-q", wavPath] : [wavPath];
113
+
114
+ return new Promise((resolve, reject) => {
115
+ const player = spawn(bin, args, { stdio: "ignore" });
116
+ currentPlayer = player;
117
+
118
+ player.on("error", (err) => {
119
+ currentPlayer = null;
120
+ reject(err);
121
+ });
122
+
123
+ player.on("close", (code, signal) => {
124
+ currentPlayer = null;
125
+ if (!running || signal === "SIGTERM" || signal === "SIGINT") {
126
+ resolve();
127
+ return;
128
+ }
129
+ if (code === 0) resolve();
130
+ else reject(new Error(`${bin} exited with code ${code}`));
131
+ });
132
+ });
133
+ }
134
+
135
+ async function cleanupTemp(path) {
136
+ tempFiles.delete(path);
137
+ try {
138
+ await unlink(path);
139
+ } catch {
140
+ // already gone
141
+ }
142
+ }
143
+
144
+ async function playGesture(excited) {
145
+ const { wav } = excited ? synthesizeExcited() : synthesizeRandom();
146
+ const wavPath = join(tmpdir(), `agenticros-eyes-${randomUUID()}.wav`);
147
+ tempFiles.add(wavPath);
148
+
149
+ try {
150
+ await writeFile(wavPath, wav);
151
+ if (!running) return;
152
+ await playWav(wavPath);
153
+ } catch (err) {
154
+ if (running) {
155
+ console.warn(
156
+ `Sound playback error: ${err instanceof Error ? err.message : String(err)}`,
157
+ );
158
+ }
159
+ } finally {
160
+ await cleanupTemp(wavPath);
161
+ }
162
+ }
163
+
164
+ async function soundLoop() {
165
+ while (running) {
166
+ if (pendingExcitement > 0) {
167
+ pendingExcitement -= 1;
168
+ await playGesture(true);
169
+ if (pendingExcitement > 0) continue;
170
+ } else {
171
+ await playGesture(false);
172
+ if (pendingExcitement > 0) continue;
173
+ }
174
+
175
+ if (!running) break;
176
+ await sleepInterruptible(randomGapMs());
177
+ }
178
+ }
179
+
180
+ /** Start idle chirps + accept excite() from cmd_vel. */
181
+ export function startSoundLoop() {
182
+ if (running) return;
183
+ const bin = findPlayer();
184
+ if (!bin) {
185
+ if (!playerWarned) {
186
+ playerWarned = true;
187
+ console.warn(
188
+ "No audio player found (afplay / paplay / aplay). R2D2 sounds disabled.",
189
+ );
190
+ }
191
+ return;
192
+ }
193
+ running = true;
194
+ console.log(
195
+ `R2D2 sounds on (${bin}; idle gaps ${MIN_GAP_MS}–${MAX_GAP_MS} ms, ` +
196
+ `excite cooldown ${EXCITE_COOLDOWN_MS} ms)`,
197
+ );
198
+ loopPromise = soundLoop().catch((err) => {
199
+ console.warn(`Sound loop error: ${err instanceof Error ? err.message : String(err)}`);
200
+ });
201
+ }
202
+
203
+ /** Stop playback and clean temp WAVs. */
204
+ export async function stopSoundLoop() {
205
+ if (!running) return;
206
+ running = false;
207
+ if (wakeSleep) wakeSleep();
208
+ stopCurrentPlayback();
209
+ if (loopPromise) {
210
+ try {
211
+ await loopPromise;
212
+ } catch {
213
+ // ignore
214
+ }
215
+ loopPromise = null;
216
+ }
217
+ await Promise.all([...tempFiles].map(cleanupTemp));
218
+ }
package/lib/synth.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Procedural R2D2-style synthesizer.
3
+ *
4
+ * Adapted from https://github.com/chrismatthieu/r2d2 (Apache License 2.0).
5
+ * See ../NOTICE.
6
+ */
7
+ const SAMPLE_RATE = 22050;
8
+ const AMPLITUDE = 0.35;
9
+
10
+ function rand(min, max) {
11
+ return min + Math.random() * (max - min);
12
+ }
13
+
14
+ function randInt(min, max) {
15
+ return Math.floor(rand(min, max + 1));
16
+ }
17
+
18
+ function pick(arr) {
19
+ return arr[Math.floor(Math.random() * arr.length)];
20
+ }
21
+
22
+ /** Soft attack/decay envelope to avoid clicks. */
23
+ function envelope(i, n, attack = 0.02, release = 0.08) {
24
+ const a = Math.max(1, Math.floor(n * attack));
25
+ const r = Math.max(1, Math.floor(n * release));
26
+ if (i < a) return i / a;
27
+ if (i > n - r) return (n - i) / r;
28
+ return 1;
29
+ }
30
+
31
+ function encodeWav(samples) {
32
+ const dataSize = samples.length * 2;
33
+ const buffer = Buffer.alloc(44 + dataSize);
34
+
35
+ buffer.write('RIFF', 0);
36
+ buffer.writeUInt32LE(36 + dataSize, 4);
37
+ buffer.write('WAVE', 8);
38
+ buffer.write('fmt ', 12);
39
+ buffer.writeUInt32LE(16, 16); // PCM chunk size
40
+ buffer.writeUInt16LE(1, 20); // audio format = PCM
41
+ buffer.writeUInt16LE(1, 22); // mono
42
+ buffer.writeUInt32LE(SAMPLE_RATE, 24);
43
+ buffer.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
44
+ buffer.writeUInt16LE(2, 32); // block align
45
+ buffer.writeUInt16LE(16, 34); // bits per sample
46
+ buffer.write('data', 36);
47
+ buffer.writeUInt32LE(dataSize, 40);
48
+
49
+ for (let i = 0; i < samples.length; i++) {
50
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
51
+ buffer.writeInt16LE(Math.round(clamped * 32767), 44 + i * 2);
52
+ }
53
+
54
+ return buffer;
55
+ }
56
+
57
+ function concatSamples(parts) {
58
+ const total = parts.reduce((sum, p) => sum + p.length, 0);
59
+ const out = new Float32Array(total);
60
+ let offset = 0;
61
+ for (const part of parts) {
62
+ out.set(part, offset);
63
+ offset += part.length;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function silence(seconds) {
69
+ return new Float32Array(Math.max(0, Math.floor(seconds * SAMPLE_RATE)));
70
+ }
71
+
72
+ /** Constant-frequency sine with envelope. */
73
+ function tone(freq, seconds) {
74
+ const n = Math.max(1, Math.floor(seconds * SAMPLE_RATE));
75
+ const samples = new Float32Array(n);
76
+ let phase = 0;
77
+ const step = (2 * Math.PI * freq) / SAMPLE_RATE;
78
+ for (let i = 0; i < n; i++) {
79
+ samples[i] = Math.sin(phase) * AMPLITUDE * envelope(i, n);
80
+ phase += step;
81
+ }
82
+ return samples;
83
+ }
84
+
85
+ /** Linear frequency sweep (chirp). */
86
+ function chirpSamples(f0, f1, seconds) {
87
+ const n = Math.max(1, Math.floor(seconds * SAMPLE_RATE));
88
+ const samples = new Float32Array(n);
89
+ let phase = 0;
90
+ for (let i = 0; i < n; i++) {
91
+ const t = i / (n - 1 || 1);
92
+ const freq = f0 + (f1 - f0) * t;
93
+ samples[i] = Math.sin(phase) * AMPLITUDE * envelope(i, n, 0.03, 0.12);
94
+ phase += (2 * Math.PI * freq) / SAMPLE_RATE;
95
+ }
96
+ return samples;
97
+ }
98
+
99
+ function makeChirp() {
100
+ const up = Math.random() < 0.55;
101
+ const low = rand(700, 1200);
102
+ const high = rand(1600, 2800);
103
+ const seconds = rand(0.08, 0.25);
104
+ return chirpSamples(up ? low : high, up ? high : low, seconds);
105
+ }
106
+
107
+ function makeWarble() {
108
+ const center = rand(900, 1800);
109
+ const count = randInt(3, 6);
110
+ const parts = [];
111
+ for (let i = 0; i < count; i++) {
112
+ const offset = (i % 2 === 0 ? 1 : -1) * rand(120, 450);
113
+ const freq = Math.max(400, center + offset);
114
+ parts.push(tone(freq, rand(0.04, 0.09)));
115
+ if (i < count - 1) parts.push(silence(rand(0.008, 0.025)));
116
+ }
117
+ return concatSamples(parts);
118
+ }
119
+
120
+ function makeBeeps() {
121
+ const count = randInt(2, 5);
122
+ const base = rand(800, 2000);
123
+ const parts = [];
124
+ for (let i = 0; i < count; i++) {
125
+ const freq = base * rand(0.85, 1.25);
126
+ parts.push(tone(freq, rand(0.035, 0.08)));
127
+ if (i < count - 1) parts.push(silence(rand(0.02, 0.07)));
128
+ }
129
+ return concatSamples(parts);
130
+ }
131
+
132
+ /** Fast, high, dense — used on active cmd_vel (excited). */
133
+ function makeExcited() {
134
+ const parts = [];
135
+ const bursts = randInt(2, 4);
136
+ for (let b = 0; b < bursts; b++) {
137
+ const kind = randInt(0, 2);
138
+ if (kind === 0) {
139
+ parts.push(chirpSamples(rand(1400, 2200), rand(2600, 3800), rand(0.05, 0.12)));
140
+ } else if (kind === 1) {
141
+ const center = rand(1600, 2600);
142
+ const count = randInt(4, 8);
143
+ for (let i = 0; i < count; i++) {
144
+ const offset = (i % 2 === 0 ? 1 : -1) * rand(200, 600);
145
+ parts.push(tone(Math.max(800, center + offset), rand(0.025, 0.05)));
146
+ if (i < count - 1) parts.push(silence(rand(0.004, 0.012)));
147
+ }
148
+ } else {
149
+ const count = randInt(3, 6);
150
+ const base = rand(1500, 2800);
151
+ for (let i = 0; i < count; i++) {
152
+ parts.push(tone(base * rand(0.9, 1.35), rand(0.02, 0.045)));
153
+ if (i < count - 1) parts.push(silence(rand(0.01, 0.03)));
154
+ }
155
+ }
156
+ if (b < bursts - 1) parts.push(silence(rand(0.02, 0.06)));
157
+ }
158
+ return concatSamples(parts);
159
+ }
160
+
161
+ const GESTURES = [
162
+ { name: 'chirp', fn: makeChirp },
163
+ { name: 'warble', fn: makeWarble },
164
+ { name: 'beeps', fn: makeBeeps },
165
+ ];
166
+
167
+ /**
168
+ * Synthesize a random R2D2-style gesture.
169
+ * @returns {{ name: string, wav: Buffer }}
170
+ */
171
+ export function synthesizeRandom() {
172
+ const gesture = pick(GESTURES);
173
+ const samples = gesture.fn();
174
+ return { name: gesture.name, wav: encodeWav(samples) };
175
+ }
176
+
177
+ /**
178
+ * Synthesize an excited R2D2 burst (higher, faster, denser).
179
+ * @returns {{ name: string, wav: Buffer }}
180
+ */
181
+ export function synthesizeExcited() {
182
+ return { name: 'excited', wav: encodeWav(makeExcited()) };
183
+ }
184
+
185
+ export { SAMPLE_RATE };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agenticros/eyes",
3
- "version": "1.0.0",
4
- "description": "Fullscreen robot eyes for tablet/display devices, driven by ROS 2 cmd_vel Twist",
3
+ "version": "1.1.0",
4
+ "description": "Fullscreen robot eyes for tablet/display devices, driven by ROS 2 cmd_vel Twist, with R2D2-style sounds",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "bin": {
@@ -9,8 +9,10 @@
9
9
  },
10
10
  "files": [
11
11
  "src",
12
+ "lib",
12
13
  "public",
13
- "README.md"
14
+ "README.md",
15
+ "NOTICE"
14
16
  ],
15
17
  "scripts": {
16
18
  "build": "node -e \"process.exit(0)\"",
package/src/index.js CHANGED
@@ -4,6 +4,8 @@
4
4
  *
5
5
  * Subscribes to CMD_VEL_TOPIC for gaze (left/right turns). Optionally publishes
6
6
  * the same topic from invisible WASD keyboard teleop (disabled with --no-teleop).
7
+ * Plays procedural R2D2 chirps (idle) and excited bursts on active cmd_vel
8
+ * (disabled with --no-sound).
7
9
  *
8
10
  * Env (set by `agenticros eyes` from ~/.agenticros/config.json):
9
11
  * CMD_VEL_TOPIC, MAX_LINEAR_VELOCITY, MAX_ANGULAR_VELOCITY, PORT, …
@@ -16,6 +18,12 @@ import { fileURLToPath } from "url";
16
18
  import { spawn, execFileSync } from "child_process";
17
19
  import { WebSocketServer } from "ws";
18
20
 
21
+ import {
22
+ exciteFromTwist,
23
+ startSoundLoop,
24
+ stopSoundLoop,
25
+ } from "../lib/sounds.js";
26
+
19
27
  const require = createRequire(import.meta.url);
20
28
  let rclnodejs;
21
29
  try {
@@ -41,6 +49,9 @@ const NO_BROWSER = process.argv.includes("--no-browser");
41
49
  const NO_TELEOP =
42
50
  process.argv.includes("--no-teleop") ||
43
51
  process.env.AGENTICROS_EYES_NO_TELEOP === "1";
52
+ const NO_SOUND =
53
+ process.argv.includes("--no-sound") ||
54
+ process.env.AGENTICROS_EYES_NO_SOUND === "1";
44
55
 
45
56
  const MAX_LINEAR = Number(process.env.MAX_LINEAR_VELOCITY || 1.0);
46
57
  const MAX_ANGULAR = Number(process.env.MAX_ANGULAR_VELOCITY || 1.5);
@@ -348,6 +359,9 @@ async function main() {
348
359
  gazeX: state.gazeX,
349
360
  driving: state.driving,
350
361
  });
362
+ if (!NO_SOUND) {
363
+ exciteFromTwist(msg, ANGULAR_DEADZONE);
364
+ }
351
365
  });
352
366
 
353
367
  setInterval(() => {
@@ -377,6 +391,11 @@ async function main() {
377
391
  `(base linear=${TELOP_LINEAR}, angular=${TELOP_ANGULAR})`,
378
392
  );
379
393
  }
394
+ if (NO_SOUND) {
395
+ console.log("R2D2 sounds disabled (--no-sound)");
396
+ } else {
397
+ startSoundLoop();
398
+ }
380
399
  if (!NO_BROWSER) {
381
400
  launchKiosk(url);
382
401
  } else {
@@ -389,6 +408,7 @@ async function main() {
389
408
  const shutdown = async () => {
390
409
  console.log("\nShutting down…");
391
410
  try {
411
+ await stopSoundLoop();
392
412
  publishStop();
393
413
  wss.close();
394
414
  server.close();