@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 +2 -0
- package/README.md +8 -3
- package/lib/sounds.js +218 -0
- package/lib/synth.js +185 -0
- package/package.json +5 -3
- package/src/index.js +20 -0
package/NOTICE
ADDED
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,
|
|
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.
|
|
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();
|