infinicode 2.8.85 → 2.8.86
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/dist/robopark/serve.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { spawn, execSync } from 'node:child_process';
|
|
12
|
-
import { existsSync } from 'node:fs';
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
13
14
|
import { dirname, join } from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
function pkgDir() {
|
|
@@ -68,6 +69,33 @@ function findPython() {
|
|
|
68
69
|
// ENOENT/"not found" error from the OS rather than a silent fallback.
|
|
69
70
|
return process.platform === 'win32' ? 'python' : 'python3';
|
|
70
71
|
}
|
|
72
|
+
function resolveSchedulerDataDir(explicit) {
|
|
73
|
+
if (explicit)
|
|
74
|
+
return explicit;
|
|
75
|
+
if (process.env.SCHEDULER_DATA_DIR)
|
|
76
|
+
return process.env.SCHEDULER_DATA_DIR;
|
|
77
|
+
const stable = join(homedir(), '.robopark', 'scheduler');
|
|
78
|
+
const stableDb = join(stable, 'scheduler.db');
|
|
79
|
+
if (existsSync(stableDb))
|
|
80
|
+
return stable;
|
|
81
|
+
// Older releases stored data beside whichever npm installation launched
|
|
82
|
+
// the scheduler. Local, global and npx installs therefore created separate
|
|
83
|
+
// databases. Migrate the largest existing DB once into stable user storage.
|
|
84
|
+
const legacyCandidates = [join(pkgDir(), '..', 'data')];
|
|
85
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
86
|
+
legacyCandidates.push(join(process.env.APPDATA, 'npm', 'node_modules', 'data'));
|
|
87
|
+
}
|
|
88
|
+
const legacy = legacyCandidates
|
|
89
|
+
.filter((candidate, index, all) => all.indexOf(candidate) === index)
|
|
90
|
+
.filter(candidate => existsSync(join(candidate, 'scheduler.db')))
|
|
91
|
+
.sort((a, b) => statSync(join(b, 'scheduler.db')).size - statSync(join(a, 'scheduler.db')).size)[0];
|
|
92
|
+
mkdirSync(stable, { recursive: true });
|
|
93
|
+
if (legacy) {
|
|
94
|
+
cpSync(legacy, stable, { recursive: true, force: false, errorOnExist: false });
|
|
95
|
+
console.log(chalk.yellow(` migrated scheduler data: ${legacy} -> ${stable}`));
|
|
96
|
+
}
|
|
97
|
+
return stable;
|
|
98
|
+
}
|
|
71
99
|
/** Resolve the actual infinicode CLI entry point so `robopark setup --start`
|
|
72
100
|
* works even when global bins are not on PATH.
|
|
73
101
|
*
|
|
@@ -110,6 +138,7 @@ export async function roboparkServe(opts) {
|
|
|
110
138
|
const python = findPython();
|
|
111
139
|
const schedulerDir = dirname(schedulerPath);
|
|
112
140
|
const requirements = join(schedulerDir, 'requirements.txt');
|
|
141
|
+
const dataDir = resolveSchedulerDataDir(opts.dataDir);
|
|
113
142
|
// Ensure scheduler Python dependencies are installed before starting.
|
|
114
143
|
if (existsSync(requirements)) {
|
|
115
144
|
console.log(chalk.dim(' ensuring scheduler Python deps…'));
|
|
@@ -125,7 +154,7 @@ export async function roboparkServe(opts) {
|
|
|
125
154
|
...process.env,
|
|
126
155
|
SCHEDULER_HOST: host,
|
|
127
156
|
SCHEDULER_PORT: String(port),
|
|
128
|
-
SCHEDULER_DATA_DIR:
|
|
157
|
+
SCHEDULER_DATA_DIR: dataDir,
|
|
129
158
|
};
|
|
130
159
|
if (opts.gateway)
|
|
131
160
|
env.INFINIBOT_GATEWAY_URL = opts.gateway;
|
|
@@ -138,6 +167,7 @@ export async function roboparkServe(opts) {
|
|
|
138
167
|
console.log(` scheduler: ${chalk.cyan(schedulerPath)}`);
|
|
139
168
|
console.log(` python: ${chalk.cyan(python)}`);
|
|
140
169
|
console.log(` listen: ${chalk.cyan(`${host}:${port}`)}`);
|
|
170
|
+
console.log(` data: ${chalk.cyan(dataDir)}`);
|
|
141
171
|
if (opts.gateway)
|
|
142
172
|
console.log(` gateway: ${chalk.cyan(opts.gateway)}`);
|
|
143
173
|
console.log();
|
package/package.json
CHANGED
|
@@ -604,6 +604,9 @@ async def init_db():
|
|
|
604
604
|
("sessions", "voice_stack_id", "TEXT"),
|
|
605
605
|
("sessions", "initiated_by", "TEXT"),
|
|
606
606
|
("sessions", "event_token_hash", "TEXT"),
|
|
607
|
+
# Speaker tests are executed only by preview_agent, which can
|
|
608
|
+
# release and restore the active media publisher around ALSA I/O.
|
|
609
|
+
("shell_requests", "claimed_at", "TEXT"),
|
|
607
610
|
):
|
|
608
611
|
try:
|
|
609
612
|
await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
|
|
@@ -3317,8 +3320,9 @@ async def device_supervisor_status(device_id: str, payload: SupervisorStatusRepo
|
|
|
3317
3320
|
pending = []
|
|
3318
3321
|
try:
|
|
3319
3322
|
async with db.execute(
|
|
3320
|
-
"SELECT service_name, action, kind, params, id FROM shell_requests "
|
|
3321
|
-
"WHERE device_id = ? AND completed_at IS NULL
|
|
3323
|
+
"SELECT service_name, action, kind, params, id FROM shell_requests "
|
|
3324
|
+
"WHERE device_id = ? AND completed_at IS NULL AND kind != 'speaker_test' "
|
|
3325
|
+
"ORDER BY requested_at",
|
|
3322
3326
|
(device_id,),
|
|
3323
3327
|
) as c:
|
|
3324
3328
|
shell_rows = [dict(r) for r in await c.fetchall()]
|
|
@@ -3696,13 +3700,26 @@ async def next_device_speaker_test(device_id: str, authorization: Optional[str]
|
|
|
3696
3700
|
"""Allow the always-running preview agent to execute audio tests."""
|
|
3697
3701
|
if not await _authorize_device(device_id, authorization):
|
|
3698
3702
|
raise HTTPException(401, "Invalid device token")
|
|
3703
|
+
now = datetime.utcnow()
|
|
3704
|
+
stale_before = (now - timedelta(seconds=45)).isoformat()
|
|
3699
3705
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
3700
3706
|
db.row_factory = aiosqlite.Row
|
|
3707
|
+
await db.execute("BEGIN IMMEDIATE")
|
|
3701
3708
|
async with db.execute(
|
|
3702
3709
|
"SELECT id, params FROM shell_requests WHERE device_id = ? AND kind = 'speaker_test' "
|
|
3703
|
-
"AND completed_at IS NULL
|
|
3710
|
+
"AND completed_at IS NULL AND (claimed_at IS NULL OR claimed_at < ?) "
|
|
3711
|
+
"ORDER BY requested_at LIMIT 1", (device_id, stale_before),
|
|
3704
3712
|
) as cursor:
|
|
3705
3713
|
row = await cursor.fetchone()
|
|
3714
|
+
if row:
|
|
3715
|
+
claimed = await db.execute(
|
|
3716
|
+
"UPDATE shell_requests SET claimed_at = ? WHERE id = ? AND completed_at IS NULL "
|
|
3717
|
+
"AND (claimed_at IS NULL OR claimed_at < ?)",
|
|
3718
|
+
(now.isoformat(), row["id"], stale_before),
|
|
3719
|
+
)
|
|
3720
|
+
if claimed.rowcount != 1:
|
|
3721
|
+
row = None
|
|
3722
|
+
await db.commit()
|
|
3706
3723
|
return {"request": {"id": row["id"], "params": json.loads(row["params"] or "{}")} if row else None}
|
|
3707
3724
|
|
|
3708
3725
|
|
|
@@ -4171,25 +4188,35 @@ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[st
|
|
|
4171
4188
|
|
|
4172
4189
|
|
|
4173
4190
|
async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
4174
|
-
"""Public helper to resolve the effective voice config for a robot id."""
|
|
4175
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
4176
|
-
db.row_factory = aiosqlite.Row
|
|
4177
|
-
|
|
4191
|
+
"""Public helper to resolve the effective voice config for a robot id."""
|
|
4192
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
4193
|
+
db.row_factory = aiosqlite.Row
|
|
4194
|
+
# Dashboard routes commonly use the human robot name ("bmw"), while
|
|
4195
|
+
# assignments are persisted against the enrolled device id. Resolve
|
|
4196
|
+
# that alias once so character, stack and greetings use one identity.
|
|
4197
|
+
async with db.execute(
|
|
4198
|
+
"SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
|
|
4199
|
+
"ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
|
|
4200
|
+
(robot_id, robot_id, robot_id),
|
|
4201
|
+
) as c:
|
|
4202
|
+
canonical_device = await c.fetchone()
|
|
4203
|
+
canonical_id = canonical_device["id"] if canonical_device else robot_id
|
|
4204
|
+
stack_dict = await _resolve_voice_stack_for_robot(db, canonical_id)
|
|
4178
4205
|
|
|
4179
4206
|
# Determine character preset + name
|
|
4180
4207
|
character_id = None
|
|
4181
4208
|
character_name = None
|
|
4182
4209
|
system_prompt = None
|
|
4183
4210
|
async with db.execute(
|
|
4184
|
-
"SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (
|
|
4211
|
+
"SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (canonical_id,)
|
|
4185
4212
|
) as c:
|
|
4186
4213
|
rvc = await c.fetchone()
|
|
4187
4214
|
if rvc and rvc["character_preset_id"]:
|
|
4188
4215
|
character_id = rvc["character_preset_id"]
|
|
4189
4216
|
else:
|
|
4190
4217
|
async with db.execute(
|
|
4191
|
-
"SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
|
|
4192
|
-
(
|
|
4218
|
+
"SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
|
|
4219
|
+
(canonical_id, canonical_id),
|
|
4193
4220
|
) as c:
|
|
4194
4221
|
row = await c.fetchone()
|
|
4195
4222
|
if row:
|
|
@@ -4204,21 +4231,23 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
|
4204
4231
|
system_prompt = row["system_prompt"]
|
|
4205
4232
|
if not character_name:
|
|
4206
4233
|
async with db.execute(
|
|
4207
|
-
"SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
|
|
4208
|
-
(
|
|
4234
|
+
"SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
|
|
4235
|
+
(canonical_id, canonical_id),
|
|
4209
4236
|
) as c:
|
|
4210
4237
|
row = await c.fetchone()
|
|
4211
4238
|
if row:
|
|
4212
4239
|
character_name = row["name"]
|
|
4213
4240
|
|
|
4214
|
-
async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (
|
|
4241
|
+
async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (canonical_id,)) as c:
|
|
4215
4242
|
device_row = await c.fetchone()
|
|
4216
4243
|
greeting_phrases = []
|
|
4217
4244
|
if device_row and device_row["greeting_phrases"]:
|
|
4218
4245
|
try:
|
|
4219
4246
|
greeting_phrases = [str(p) for p in _json.loads(device_row["greeting_phrases"]) if str(p).strip()]
|
|
4220
|
-
except Exception:
|
|
4221
|
-
greeting_phrases = []
|
|
4247
|
+
except Exception:
|
|
4248
|
+
greeting_phrases = []
|
|
4249
|
+
if not greeting_phrases and (character_name or character_id):
|
|
4250
|
+
greeting_phrases = [f"Hello, I am {character_name or character_id}. How can I help you?"]
|
|
4222
4251
|
if stack_dict:
|
|
4223
4252
|
stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
|
|
4224
4253
|
voice_stack = VoiceStack(**stack_dict) if stack_dict else None
|
|
@@ -1233,44 +1233,95 @@ class LiveKitPublisher:
|
|
|
1233
1233
|
if self._capture and self.video_source:
|
|
1234
1234
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
1235
1235
|
|
|
1236
|
-
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
except Exception as e:
|
|
1240
|
-
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1241
|
-
return
|
|
1242
|
-
OUT_RATE = 48000
|
|
1243
|
-
OUT_CHANNELS = 2
|
|
1244
|
-
pa = pyaudio.PyAudio()
|
|
1245
|
-
# Same MME-vs-WASAPI gotcha as mic capture (see PyAudioCapture._resolve_device):
|
|
1246
|
-
# PyAudio's global default output device resolves through MME, which on this
|
|
1247
|
-
# machine will accept writes and report success with zero exceptions while
|
|
1248
|
-
# never actually reaching the real speakers. Prefer WASAPI's default output,
|
|
1249
|
-
# which is what Windows' own volume mixer/meter is backed by.
|
|
1250
|
-
output_device_index = None
|
|
1236
|
+
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
1237
|
+
OUT_RATE = 48000
|
|
1238
|
+
OUT_CHANNELS = 2
|
|
1251
1239
|
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1240
|
+
pa = None
|
|
1241
|
+
output_device_index = None
|
|
1242
|
+
|
|
1243
|
+
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1244
|
+
# but the numeric indices are not stable across PyAudio builds. More
|
|
1245
|
+
# importantly, BMW's production USB adapter is already proven through
|
|
1246
|
+
# ALSA's plughw conversion path. Use that exact endpoint for live TTS
|
|
1247
|
+
# instead of reopening the unrelated PyAudio index.
|
|
1248
|
+
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1249
|
+
import re
|
|
1250
|
+
import subprocess
|
|
1251
|
+
|
|
1252
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1253
|
+
if not match:
|
|
1254
|
+
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1255
|
+
return
|
|
1256
|
+
alsa_device = f"plug{match.group(1)}"
|
|
1257
|
+
process = subprocess.Popen(
|
|
1258
|
+
[
|
|
1259
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1260
|
+
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1261
|
+
],
|
|
1262
|
+
stdin=subprocess.PIPE,
|
|
1263
|
+
stderr=subprocess.PIPE,
|
|
1264
|
+
)
|
|
1265
|
+
if process.stdin is None:
|
|
1266
|
+
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1267
|
+
process.kill()
|
|
1268
|
+
return
|
|
1269
|
+
|
|
1270
|
+
class _AplayOutput:
|
|
1271
|
+
def write(self, chunk: bytes) -> None:
|
|
1272
|
+
if process.poll() is not None:
|
|
1273
|
+
detail = ""
|
|
1274
|
+
if process.stderr is not None:
|
|
1275
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1276
|
+
raise OSError(detail or f"aplay exited {process.returncode}")
|
|
1277
|
+
process.stdin.write(chunk)
|
|
1278
|
+
process.stdin.flush()
|
|
1279
|
+
|
|
1280
|
+
def stop_stream(self) -> None:
|
|
1281
|
+
if process.stdin and not process.stdin.closed:
|
|
1282
|
+
process.stdin.close()
|
|
1283
|
+
try:
|
|
1284
|
+
process.wait(timeout=2)
|
|
1285
|
+
except subprocess.TimeoutExpired:
|
|
1286
|
+
process.terminate()
|
|
1287
|
+
|
|
1288
|
+
def close(self) -> None:
|
|
1289
|
+
return
|
|
1290
|
+
|
|
1291
|
+
out = _AplayOutput()
|
|
1292
|
+
output_device_index = alsa_device
|
|
1293
|
+
else:
|
|
1294
|
+
try:
|
|
1295
|
+
import pyaudio
|
|
1296
|
+
except Exception as e:
|
|
1297
|
+
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1298
|
+
return
|
|
1299
|
+
pa = pyaudio.PyAudio()
|
|
1300
|
+
# Same MME-vs-WASAPI gotcha as mic capture (see
|
|
1301
|
+
# PyAudioCapture._resolve_device): prefer the endpoint backing the
|
|
1302
|
+
# Windows volume mixer instead of the silent MME default.
|
|
1303
|
+
if selected_output.strip().isdigit():
|
|
1304
|
+
output_device_index = int(selected_output.strip())
|
|
1305
|
+
try:
|
|
1306
|
+
if selected_output.lower() == "default":
|
|
1307
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
1308
|
+
idx = wasapi.get("defaultOutputDevice")
|
|
1309
|
+
if idx is not None and idx >= 0:
|
|
1310
|
+
output_device_index = idx
|
|
1311
|
+
elif output_device_index is None:
|
|
1312
|
+
needle = selected_output.lower()
|
|
1313
|
+
for i in range(pa.get_device_count()):
|
|
1314
|
+
info = pa.get_device_info_by_index(i)
|
|
1315
|
+
if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
|
|
1316
|
+
output_device_index = i
|
|
1317
|
+
break
|
|
1318
|
+
except Exception as e:
|
|
1319
|
+
logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
|
|
1320
|
+
out = pa.open(
|
|
1321
|
+
format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
|
|
1322
|
+
output_device_index=output_device_index,
|
|
1323
|
+
frames_per_buffer=960,
|
|
1324
|
+
)
|
|
1274
1325
|
logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
|
|
1275
1326
|
frame_count = 0
|
|
1276
1327
|
mismatch_logged = False
|
|
@@ -1409,9 +1460,10 @@ class LiveKitPublisher:
|
|
|
1409
1460
|
f"audio out: {sid} received {frame_count} frames, "
|
|
1410
1461
|
f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
|
|
1411
1462
|
)
|
|
1412
|
-
out.stop_stream()
|
|
1413
|
-
out.close()
|
|
1414
|
-
pa
|
|
1463
|
+
out.stop_stream()
|
|
1464
|
+
out.close()
|
|
1465
|
+
if pa is not None:
|
|
1466
|
+
pa.terminate()
|
|
1415
1467
|
|
|
1416
1468
|
async def stop(self) -> None:
|
|
1417
1469
|
self._stop_event.set()
|