infinicode 2.8.73 → 2.8.76

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.
@@ -3,8 +3,9 @@ RoboPark Session Scheduler - Backend API
3
3
  Manages robot fleet, LiveKit servers, and session orchestration
4
4
  """
5
5
 
6
- import os
7
- import asyncio
6
+ import os
7
+ import asyncio
8
+ import base64
8
9
  import contextvars
9
10
  import json
10
11
  import logging
@@ -210,6 +211,7 @@ class DeviceBootstrapRequest(BaseModel):
210
211
  name: str
211
212
  lan_ip: Optional[str] = None
212
213
  tailscale_ip: Optional[str] = None
214
+ livekit_url: Optional[str] = None
213
215
 
214
216
  class DeviceEnrollResponse(BaseModel):
215
217
  device_id: str
@@ -220,8 +222,9 @@ class DeviceHeartbeat(BaseModel):
220
222
  status: Optional[str] = None
221
223
  ip: Optional[str] = None
222
224
  uptime_seconds: Optional[int] = None
223
- production_mode: Optional[bool] = None
225
+ production_mode: Optional[bool] = None
224
226
  device_inventory: Optional[dict] = None
227
+ livekit_url: Optional[str] = None
225
228
 
226
229
  class PipelineEventPayload(BaseModel):
227
230
  stage: str
@@ -2811,16 +2814,17 @@ async def bootstrap_device(
2811
2814
  await db.execute(
2812
2815
  """UPDATE devices SET token_hash = ?, status = 'enrolled',
2813
2816
  lan_ip = COALESCE(?, lan_ip), tailscale_ip = COALESCE(?, tailscale_ip),
2817
+ livekit_url = COALESCE(?, livekit_url),
2814
2818
  enrolled_at = COALESCE(enrolled_at, ?) WHERE id = ?""",
2815
- (new_hash, payload.lan_ip, payload.tailscale_ip, now, device_id),
2819
+ (new_hash, payload.lan_ip, payload.tailscale_ip, payload.livekit_url, now, device_id),
2816
2820
  )
2817
2821
  else:
2818
2822
  device_id = f"dev_{secrets.token_hex(4)}"
2819
2823
  await db.execute(
2820
2824
  """INSERT INTO devices
2821
- (id, name, lan_ip, tailscale_ip, token_hash, status, enrolled_at, created_at)
2822
- VALUES (?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
2823
- (device_id, name, payload.lan_ip, payload.tailscale_ip, new_hash, now, now),
2825
+ (id, name, lan_ip, tailscale_ip, livekit_url, token_hash, status, enrolled_at, created_at)
2826
+ VALUES (?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
2827
+ (device_id, name, payload.lan_ip, payload.tailscale_ip, payload.livekit_url, new_hash, now, now),
2824
2828
  )
2825
2829
  await db.execute(
2826
2830
  "INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
@@ -2988,16 +2992,18 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
2988
2992
  """UPDATE devices
2989
2993
  SET status = ?, last_heartbeat = ?,
2990
2994
  lan_ip = COALESCE(?, lan_ip),
2991
- last_seen_ip = COALESCE(?, last_seen_ip),
2992
- device_inventory = COALESCE(?, device_inventory)
2995
+ last_seen_ip = COALESCE(?, last_seen_ip),
2996
+ device_inventory = COALESCE(?, device_inventory),
2997
+ livekit_url = COALESCE(?, livekit_url)
2993
2998
  WHERE id = ?""",
2994
2999
  (
2995
3000
  payload.status or "online",
2996
3001
  datetime.utcnow().isoformat(),
2997
3002
  payload.ip,
2998
- payload.ip,
2999
- json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
3000
- device_id,
3003
+ payload.ip,
3004
+ json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
3005
+ payload.livekit_url,
3006
+ device_id,
3001
3007
  ),
3002
3008
  ) as cur:
3003
3009
  await db.commit()
@@ -3435,24 +3441,101 @@ async def shell_allowlist():
3435
3441
  # other shell commands (the supervisor treats speaker_test as
3436
3442
  # just another request kind).
3437
3443
 
3438
- class SpeakerTestRequest(BaseModel):
3444
+ class SpeakerTestRequest(BaseModel):
3445
+ mode: str = "tone"
3446
+ text: Optional[str] = None
3439
3447
  frequency: Optional[float] = None
3440
3448
  duration: Optional[float] = None
3441
3449
  amplitude: Optional[float] = None
3442
3450
  threshold_db: Optional[float] = None
3443
3451
  output: Optional[str] = None
3444
3452
  input: Optional[str] = None
3445
- sample_rate: Optional[int] = None
3453
+ sample_rate: Optional[int] = None
3454
+
3455
+
3456
+ async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
3457
+ """Generate configured ElevenLabs PCM once, then reuse it for device tests."""
3458
+ phrase = " ".join((text or "Hello, this is a RoboPark speaker test.").split())[:240]
3459
+ config = await _resolve_robot_voice_config(robot_id)
3460
+ stack = config.voice_stack if config else None
3461
+ provider = (stack.tts_provider if stack else "elevenlabs").lower()
3462
+ voice_id = stack.tts_voice if stack else "21m00Tcm4TlvDq8ikWAM"
3463
+ if provider != "elevenlabs":
3464
+ raise HTTPException(400, f"Cached voice test currently requires ElevenLabs; selected stack uses {provider}")
3465
+ if not ELEVENLABS_API_KEY:
3466
+ raise HTTPException(503, "ELEVENLABS_API_KEY is not configured on the scheduler")
3467
+ if not voice_id:
3468
+ raise HTTPException(400, "Selected voice stack has no ElevenLabs voice id")
3469
+ cache_dir = os.path.join(os.path.dirname(DB_PATH), "tts-test-cache")
3470
+ os.makedirs(cache_dir, exist_ok=True)
3471
+ cache_key = hashlib.sha256(f"{provider}|{voice_id}|{phrase}".encode("utf8")).hexdigest()
3472
+ cache_path = os.path.join(cache_dir, cache_key + ".pcm")
3473
+ cache_hit = os.path.isfile(cache_path)
3474
+ if cache_hit:
3475
+ with open(cache_path, "rb") as handle:
3476
+ pcm = handle.read()
3477
+ else:
3478
+ async with httpx.AsyncClient(timeout=20.0) as client:
3479
+ response = await client.post(
3480
+ f"{ELEVENLABS_BASE_URL}/v1/text-to-speech/{voice_id}",
3481
+ params={"output_format": "pcm_24000"},
3482
+ headers={"xi-api-key": ELEVENLABS_API_KEY, "Accept": "audio/pcm"},
3483
+ json={"text": phrase, "model_id": "eleven_flash_v2_5"},
3484
+ )
3485
+ if response.status_code != 200:
3486
+ detail = response.text[:240] or "no response body"
3487
+ raise HTTPException(502, f"ElevenLabs speaker test failed ({response.status_code}): {detail}")
3488
+ pcm = response.content
3489
+ if len(pcm) < 480:
3490
+ raise HTTPException(502, "ElevenLabs returned empty speaker-test audio")
3491
+ temp_path = cache_path + ".tmp"
3492
+ with open(temp_path, "wb") as handle:
3493
+ handle.write(pcm)
3494
+ os.replace(temp_path, cache_path)
3495
+ return {
3496
+ "mode": "tts", "text": phrase, "tts_provider": provider, "tts_voice": voice_id,
3497
+ "sample_rate": 24000, "audio_pcm_base64": base64.b64encode(pcm).decode("ascii"),
3498
+ "cache_hit": cache_hit,
3499
+ }
3446
3500
 
3447
3501
 
3448
3502
  @app.post("/api/robots/{robot_id}/shell/speaker-test")
3449
- async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
3503
+ async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
3450
3504
  """Queue a speaker + mic round-trip test on the robot. Returns a
3451
3505
  request_id; poll /api/robots/{id}/shell/wait/{rid} for the result."""
3452
- device_id = robot_id
3453
- params = payload.model_dump(exclude_none=True)
3454
- request_id = await _queue_shell_request_async(device_id, "speaker_test", None, params)
3455
- return {"status": "queued", "request_id": request_id, "device_id": device_id}
3506
+ async with aiosqlite.connect(DB_PATH) as db:
3507
+ db.row_factory = aiosqlite.Row
3508
+ async with db.execute(
3509
+ "SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
3510
+ "ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
3511
+ (robot_id, robot_id, robot_id),
3512
+ ) as cursor:
3513
+ device = await cursor.fetchone()
3514
+ if not device:
3515
+ raise HTTPException(404, f"Robot device not found: {robot_id}")
3516
+ device_id = device["id"]
3517
+ params = payload.model_dump(exclude_none=True)
3518
+ if payload.mode == "tts":
3519
+ params.update(await _cached_speaker_tts(robot_id, payload.text or ""))
3520
+ elif payload.mode != "tone":
3521
+ raise HTTPException(422, "mode must be tone or tts")
3522
+ request_id = await _queue_shell_request_async(device_id, "speaker_test", None, params)
3523
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
3524
+
3525
+
3526
+ @app.get("/api/devices/{device_id}/shell/next-speaker-test")
3527
+ async def next_device_speaker_test(device_id: str, authorization: Optional[str] = Header(default=None)):
3528
+ """Allow the always-running preview agent to execute audio tests."""
3529
+ if not await _authorize_device(device_id, authorization):
3530
+ raise HTTPException(401, "Invalid device token")
3531
+ async with aiosqlite.connect(DB_PATH) as db:
3532
+ db.row_factory = aiosqlite.Row
3533
+ async with db.execute(
3534
+ "SELECT id, params FROM shell_requests WHERE device_id = ? AND kind = 'speaker_test' "
3535
+ "AND completed_at IS NULL ORDER BY requested_at LIMIT 1", (device_id,),
3536
+ ) as cursor:
3537
+ row = await cursor.fetchone()
3538
+ return {"request": {"id": row["id"], "params": json.loads(row["params"] or "{}")} if row else None}
3456
3539
 
3457
3540
 
3458
3541
  # ── Incident acknowledgement (C4) ──
@@ -3655,7 +3738,7 @@ async def device_request_session(device_id: str,
3655
3738
  return {
3656
3739
  "session_id": session_id,
3657
3740
  "server_id": server["id"],
3658
- "server_url": server["url"],
3741
+ "server_url": device["livekit_url"] or server["url"],
3659
3742
  "room_name": room_name,
3660
3743
  "token": token,
3661
3744
  "voice_config": voice_config.model_dump() if voice_config else None,
@@ -278,7 +278,7 @@ async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str
278
278
  async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
279
279
  response = await client.post(
280
280
  f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
281
- json={"name": robot_id, "lan_ip": _get_lan_ip()},
281
+ json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
282
282
  timeout=30.0,
283
283
  )
284
284
  response.raise_for_status()
@@ -302,8 +302,9 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
302
302
  import socket
303
303
  payload = {
304
304
  "enrollment_token": enrollment_token,
305
- "name": robot_id,
306
- "lan_ip": _get_lan_ip(),
305
+ "name": robot_id,
306
+ "lan_ip": _get_lan_ip(),
307
+ "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
307
308
  }
308
309
  async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
309
310
  r = await client.post(
@@ -346,7 +347,8 @@ async def _send_heartbeat(
346
347
  async with httpx.AsyncClient() as client:
347
348
  response = await client.post(
348
349
  f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
349
- json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory},
350
+ json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
351
+ "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
350
352
  headers=headers,
351
353
  timeout=10.0,
352
354
  )
@@ -421,7 +423,8 @@ class PreviewAgent:
421
423
  # with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
422
424
  # _check_vision_session() below for the full design.
423
425
  self._vision_session_id: Optional[str] = None
424
- self._vision_trigger_in_flight = False
426
+ self._vision_trigger_in_flight = False
427
+ self._speaker_test_in_flight = False
425
428
  self._vision_hard_deadline: float = 0.0
426
429
  self._vision_last_activity: float = 0.0
427
430
  self.production_mode = False
@@ -820,11 +823,41 @@ class PreviewAgent:
820
823
  await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
821
824
  await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
822
825
  if not production_mode and self._vision_session_id:
823
- await self._end_vision_session()
826
+ await self._end_vision_session()
827
+ await self._poll_speaker_test()
824
828
  try:
825
829
  await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
826
- except asyncio.TimeoutError:
827
- pass
830
+ except asyncio.TimeoutError:
831
+ pass
832
+
833
+ async def _poll_speaker_test(self) -> None:
834
+ """Execute queued speaker tests even when no optional supervisor runs."""
835
+ if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
836
+ return
837
+ headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
838
+ try:
839
+ response = await self._session.get(
840
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
841
+ headers=headers, timeout=5.0,
842
+ )
843
+ response.raise_for_status()
844
+ request = response.json().get("request")
845
+ if not request:
846
+ return
847
+ self._speaker_test_in_flight = True
848
+ from robot_supervisor import _speaker_roundtrip_test
849
+ result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
850
+ result_response = await self._session.post(
851
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
852
+ json={"kind": "speaker_test", "service": None, "payload": result,
853
+ "request_id": request.get("id")},
854
+ headers=headers, timeout=8.0,
855
+ )
856
+ result_response.raise_for_status()
857
+ except Exception as e:
858
+ logger.warning(f"speaker test execution failed: {e}")
859
+ finally:
860
+ self._speaker_test_in_flight = False
828
861
 
829
862
  async def _fetch_preview_state(self) -> PreviewState:
830
863
  if not self._session or not self.device_token:
@@ -863,23 +896,25 @@ class PreviewAgent:
863
896
  return
864
897
  await self._start_publisher(state)
865
898
 
866
- async def _start_publisher(self, state: PreviewState) -> None:
867
- await self._stop_publisher()
868
- if not state.url or not state.token or not state.room:
869
- return
899
+ async def _start_publisher(self, state: PreviewState) -> bool:
900
+ await self._stop_publisher()
901
+ if not state.url or not state.token or not state.room:
902
+ return False
870
903
  pub = None
871
904
  try:
872
905
  pub = LiveKitPublisher(state.url, state.token, state.room, self)
873
906
  await pub.start()
874
- self._publisher = pub
875
- logger.info(f"joined preview room {state.room}")
907
+ self._publisher = pub
908
+ logger.info(f"joined preview room {state.room}")
909
+ return True
876
910
  except Exception as e:
877
911
  logger.error(f"failed to start publisher: {e}")
878
912
  if pub is not None:
879
913
  try:
880
914
  await pub.stop()
881
- except Exception as cleanup_error:
882
- logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
915
+ except Exception as cleanup_error:
916
+ logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
917
+ return False
883
918
 
884
919
  async def _stop_publisher(self) -> None:
885
920
  if self._publisher:
@@ -1005,14 +1040,35 @@ class PreviewAgent:
1005
1040
  # and lets downstream components (audio server, vision, etc.) adapt.
1006
1041
  if voice_config and session_id:
1007
1042
  logger.info(f"scheduler voice config for session {session_id}: {voice_config}")
1008
- await self._start_publisher(state)
1009
- if session_id and self._session:
1010
- try:
1011
- await self._session.post(
1012
- f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
1013
- headers={"Authorization": f"Bearer {self.device_token}"},
1014
- timeout=10.0,
1015
- )
1043
+ joined = await self._start_publisher(state)
1044
+ if not joined:
1045
+ await self._report_pipeline(
1046
+ "livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
1047
+ details={"server_url": state.url}, once=False,
1048
+ )
1049
+ if session_id and self._session:
1050
+ try:
1051
+ response = await self._session.post(
1052
+ f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
1053
+ params={"reason": "livekit_join_failed"},
1054
+ headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
1055
+ )
1056
+ response.raise_for_status()
1057
+ except Exception as e:
1058
+ logger.debug(f"could not end failed LiveKit session: {e}")
1059
+ self._vision_session_id = None
1060
+ self._vision_hard_deadline = 0.0
1061
+ self._vision_last_activity = 0.0
1062
+ self._current_state = PreviewState()
1063
+ return
1064
+ if session_id and self._session:
1065
+ try:
1066
+ response = await self._session.post(
1067
+ f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
1068
+ headers={"Authorization": f"Bearer {self.device_token}"},
1069
+ timeout=10.0,
1070
+ )
1071
+ response.raise_for_status()
1016
1072
  except Exception as e:
1017
1073
  logger.debug(f"could not mark session joined: {e}")
1018
1074
 
@@ -30,7 +30,8 @@ Auto-start on boot:
30
30
  """
31
31
  from __future__ import annotations
32
32
 
33
- import argparse
33
+ import argparse
34
+ import base64
34
35
  import json
35
36
  import logging
36
37
  import math
@@ -493,7 +494,7 @@ def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
493
494
  return None
494
495
 
495
496
 
496
- def _speaker_roundtrip_test(params: dict) -> dict:
497
+ def _speaker_roundtrip_test(params: dict) -> dict:
497
498
  """Play a test tone + record the mic simultaneously; return metrics.
498
499
 
499
500
  params may include {frequency, duration, amplitude, output, input,
@@ -502,20 +503,32 @@ def _speaker_roundtrip_test(params: dict) -> dict:
502
503
  import pyaudio
503
504
  except ImportError as e:
504
505
  return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
505
- freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
506
- dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
506
+ mode = str(params.get("mode", "tone")).lower()
507
+ freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
508
+ dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
507
509
  amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
508
510
  threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
509
511
  out_name = params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
510
512
  in_name = params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
511
513
  sample_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
512
- if not (50.0 <= freq <= 8000.0):
514
+ if mode not in ("tone", "tts"):
515
+ return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
516
+ if mode == "tone" and not (50.0 <= freq <= 8000.0):
513
517
  return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
514
518
  if not (0.1 <= dur <= 3.0):
515
519
  return {"ok": False, "error": f"duration {dur}s out of allowed range (0.1..3.0)"}
516
520
  if not (0.05 <= amp <= 1.0):
517
521
  return {"ok": False, "error": f"amplitude {amp} out of allowed range (0.05..1.0)"}
518
- n_samples = int(sample_rate * dur)
522
+ mono_pcm = None
523
+ if mode == "tts":
524
+ try:
525
+ mono_pcm = base64.b64decode(params.get("audio_pcm_base64") or "", validate=True)
526
+ except Exception as e:
527
+ return {"ok": False, "error": f"invalid cached TTS audio: {e}"}
528
+ if len(mono_pcm) < 480 or len(mono_pcm) % 2:
529
+ return {"ok": False, "error": "cached TTS audio is empty or malformed"}
530
+ dur = len(mono_pcm) / 2 / sample_rate
531
+ n_samples = int(sample_rate * dur)
519
532
  if n_samples < 480:
520
533
  return {"ok": False, "error": "duration too short"}
521
534
 
@@ -529,13 +542,20 @@ def _speaker_roundtrip_test(params: dict) -> dict:
529
542
  out_label = _dev_name(out_idx)
530
543
  in_label = _dev_name(in_idx)
531
544
 
532
- frames = bytearray()
533
- peak_value = int(32767 * amp)
534
- for n in range(n_samples):
535
- env = min(1.0, n / (sample_rate * 0.004), (n_samples - n) / (sample_rate * 0.008))
536
- value = int(peak_value * env * math.sin(2 * math.pi * freq * n / sample_rate))
537
- frames.extend(struct.pack("<hh", value, value))
538
- raw = bytes(frames)
545
+ if mono_pcm is not None:
546
+ frames = bytearray()
547
+ for pos in range(0, len(mono_pcm), 2):
548
+ sample = mono_pcm[pos:pos + 2]
549
+ frames.extend(sample + sample)
550
+ raw = bytes(frames)
551
+ else:
552
+ frames = bytearray()
553
+ peak_value = int(32767 * amp)
554
+ for n in range(n_samples):
555
+ env = min(1.0, n / (sample_rate * 0.004), (n_samples - n) / (sample_rate * 0.008))
556
+ value = int(peak_value * env * math.sin(2 * math.pi * freq * n / sample_rate))
557
+ frames.extend(struct.pack("<hh", value, value))
558
+ raw = bytes(frames)
539
559
 
540
560
  recorded_peak = 0
541
561
  recorded_rms = 0.0
@@ -614,9 +634,14 @@ def _speaker_roundtrip_test(params: dict) -> dict:
614
634
  else:
615
635
  rms_db = -120.0
616
636
  pass_ = peak_db >= threshold_db
617
- return {
618
- "ok": True,
619
- "pass": pass_,
637
+ return {
638
+ "ok": True,
639
+ "pass": pass_,
640
+ "mode": mode,
641
+ "text": params.get("text"),
642
+ "cache_hit": bool(params.get("cache_hit")),
643
+ "tts_provider": params.get("tts_provider"),
644
+ "tts_voice": params.get("tts_voice"),
620
645
  "frequency": freq,
621
646
  "duration": dur,
622
647
  "duration_ms": duration_ms,