{"version":3,"sources":["../../../src/voice/avatar/playback_payload.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport type { PlaybackFinishedEvent } from '../io.js';\n\n/**\n * Parse a raw `lk.playback_finished` RPC payload into a {@link PlaybackFinishedEvent}.\n *\n * The LiveKit avatar DataStream protocol serializes this payload with **snake_case**\n * keys (`playback_position`, `synchronized_transcript`) — matching the canonical Python\n * `AvatarRunner`, which sends `json.dumps(asdict(PlaybackFinishedEvent(...)))` over a\n * dataclass whose fields are `playback_position` / `interrupted` / `synchronized_transcript`.\n * This was confirmed empirically against Anam's live avatar engine, which emits e.g.\n * `{\"playback_position\": 2.0, \"interrupted\": true, \"synchronized_transcript\": null}`.\n *\n * A bare `JSON.parse(payload) as PlaybackFinishedEvent` cast does no runtime key\n * remapping, so the camelCase `playbackPosition` field read back `undefined`. Downstream\n * that became `Math.floor(undefined * 1000) === NaN`, which `JSON.stringify` serializes as\n * `null` inside `conversation.item.truncate`; the OpenAI Realtime API then rejects the\n * truncate with an `invalid_type` error, leaving the interrupted turn desynced and the\n * conversation stalled.\n *\n * We accept snake_case as the primary (protocol-canonical) form and tolerate camelCase as\n * a fallback for any JS-side producer. A missing/`null`/non-finite position is coerced to\n * `0`, and a malformed or non-object payload yields a safe default event (position `0`, not\n * interrupted) rather than throwing — so a bad payload can neither propagate a non-finite\n * position nor escape the RPC handler and hang the unguarded `waitForPlayout()`.\n */\nexport function parsePlaybackFinishedPayload(payload: string): PlaybackFinishedEvent {\n  // Never throw out of the RPC handler: a throw would skip onPlaybackFinished and hang the\n  // unguarded waitForPlayout() on the interrupted turn. Coerce anything that is not a JSON\n  // object into an empty object and fall through to the field defaults below.\n  let raw: Record<string, unknown> = {};\n  try {\n    const parsed: unknown = JSON.parse(payload);\n    if (typeof parsed === 'object' && parsed !== null) {\n      raw = parsed as Record<string, unknown>;\n    }\n  } catch {\n    // malformed JSON; keep the empty default\n  }\n\n  const rawPosition = raw.playback_position ?? raw.playbackPosition;\n  const playbackPosition =\n    typeof rawPosition === 'number' && Number.isFinite(rawPosition) ? rawPosition : 0;\n\n  const rawTranscript = raw.synchronized_transcript ?? raw.synchronizedTranscript;\n  const synchronizedTranscript = typeof rawTranscript === 'string' ? rawTranscript : undefined;\n\n  // Only a real JSON boolean is authoritative; never coerce a truthy string like \"false\".\n  const interrupted = typeof raw.interrupted === 'boolean' ? raw.interrupted : false;\n\n  return {\n    playbackPosition,\n    interrupted,\n    synchronizedTranscript,\n  };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA4BO,SAAS,6BAA6B,SAAwC;AAInF,MAAI,MAA+B,CAAC;AACpC,MAAI;AACF,UAAM,SAAkB,KAAK,MAAM,OAAO;AAC1C,QAAI,OAAO,WAAW,YAAY,WAAW,MAAM;AACjD,YAAM;AAAA,IACR;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,QAAM,cAAc,IAAI,qBAAqB,IAAI;AACjD,QAAM,mBACJ,OAAO,gBAAgB,YAAY,OAAO,SAAS,WAAW,IAAI,cAAc;AAElF,QAAM,gBAAgB,IAAI,2BAA2B,IAAI;AACzD,QAAM,yBAAyB,OAAO,kBAAkB,WAAW,gBAAgB;AAGnF,QAAM,cAAc,OAAO,IAAI,gBAAgB,YAAY,IAAI,cAAc;AAE7E,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}