talking-head-studio 0.3.1 → 0.3.2

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.
@@ -14,6 +14,10 @@ type OpenStreamOptions = {
14
14
  *
15
15
  * Uses fetch() with streaming response body instead of EventSource because
16
16
  * React Native does not have a reliable EventSource polyfill.
17
+ *
18
+ * Retries on transient failures (network blip, 503) with exponential backoff
19
+ * up to STREAM_RETRY_BUDGET_MS. Aborts cleanly when a new requestId arrives
20
+ * or the component unmounts.
17
21
  */
18
22
  export declare function useDirectVisemeStream(onVisemes: (payload: VisemeStreamPayload) => void): {
19
23
  openStream: ({ requestId, ttsBaseUrl }: OpenStreamOptions) => void;
@@ -3,108 +3,91 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useDirectVisemeStream = useDirectVisemeStream;
4
4
  const react_1 = require("react");
5
5
  const fetch_1 = require("expo/fetch");
6
+ // How long to keep retrying a stream before giving up (ms).
7
+ const STREAM_RETRY_BUDGET_MS = 3000;
8
+ // Initial retry delay; doubles each attempt up to MAX_RETRY_DELAY_MS.
9
+ const INITIAL_RETRY_DELAY_MS = 150;
10
+ const MAX_RETRY_DELAY_MS = 1000;
6
11
  /**
7
12
  * Opens a direct SSE connection to the TTS server to receive viseme data,
8
13
  * bypassing the agent data channel relay.
9
14
  *
10
15
  * Uses fetch() with streaming response body instead of EventSource because
11
16
  * React Native does not have a reliable EventSource polyfill.
17
+ *
18
+ * Retries on transient failures (network blip, 503) with exponential backoff
19
+ * up to STREAM_RETRY_BUDGET_MS. Aborts cleanly when a new requestId arrives
20
+ * or the component unmounts.
12
21
  */
13
22
  function useDirectVisemeStream(onVisemes) {
14
- // Track the current abort controller keyed by requestId so we can detect
15
- // when a new requestId arrives and tear down the previous stream.
16
23
  const abortControllerRef = (0, react_1.useRef)(null);
17
- const activeRequestIdRef = (0, react_1.useRef)(null);
18
24
  const onVisemesRef = (0, react_1.useRef)(onVisemes);
19
25
  // Keep callback ref up to date without requiring it in openStream's dep array
20
26
  (0, react_1.useEffect)(() => {
21
27
  onVisemesRef.current = onVisemes;
22
28
  });
23
29
  const openStream = (0, react_1.useCallback)(({ requestId, ttsBaseUrl }) => {
24
- // Abort any existing stream — whether for the same or a different requestId
30
+ // Abort any existing stream for a previous request
25
31
  if (abortControllerRef.current) {
26
32
  abortControllerRef.current.abort();
27
33
  abortControllerRef.current = null;
28
34
  }
29
- activeRequestIdRef.current = requestId;
30
35
  const controller = new AbortController();
31
36
  abortControllerRef.current = controller;
32
37
  const { signal } = controller;
33
38
  // Strip trailing /v1 if present so we don't double it
34
- const base = ttsBaseUrl.replace(/\/v1\/?$/, '');
39
+ const base = ttsBaseUrl.replace(/\/v1\/?$/, "");
35
40
  const url = `${base}/v1/audio/visemes/${encodeURIComponent(requestId)}/stream`;
36
41
  (async () => {
37
- try {
38
- const response = await (0, fetch_1.fetch)(url, {
39
- headers: { Accept: "text/event-stream" },
40
- signal,
41
- });
42
- if (!response.ok) {
43
- console.warn("[VisemeSSE] Non-OK response", { requestId, status: response.status });
44
- return;
45
- }
46
- const reader = response.body?.getReader();
47
- if (!reader) {
48
- console.warn("[VisemeSSE] No response body reader", { requestId });
49
- return;
50
- }
51
- const decoder = new TextDecoder();
52
- let buffer = "";
53
- let pendingEvent = null;
54
- while (true) {
55
- const { done, value } = await reader.read();
56
- if (done)
57
- break;
58
- buffer += decoder.decode(value, { stream: true });
59
- // Split on newlines, keeping the remainder (incomplete line) in buffer
60
- const lines = buffer.split("\n");
61
- buffer = lines.pop() ?? "";
62
- for (const rawLine of lines) {
63
- const line = rawLine.trimEnd();
64
- if (line.startsWith("event:")) {
65
- pendingEvent = line.slice("event:".length).trim();
66
- }
67
- else if (line.startsWith("data:")) {
68
- if (pendingEvent === "visemes") {
69
- const jsonText = line.slice("data:".length).trim();
70
- try {
71
- const payload = JSON.parse(jsonText);
72
- console.log("[VisemeSSE] received", {
73
- requestId,
74
- cues: Array.isArray(payload.cues) ? payload.cues.length : 0,
75
- durationMs: payload.durationMs ?? null,
76
- });
77
- onVisemesRef.current(payload);
78
- }
79
- catch (parseErr) {
80
- console.warn("[VisemeSSE] JSON parse error", parseErr);
81
- }
82
- }
83
- // Reset pending event after consuming the data line
84
- pendingEvent = null;
85
- }
86
- else if (line === "") {
87
- // Empty line = end of SSE message block; reset pending event
88
- pendingEvent = null;
42
+ const startedAt = Date.now();
43
+ let retryDelay = INITIAL_RETRY_DELAY_MS;
44
+ while (!signal.aborted) {
45
+ try {
46
+ const response = await (0, fetch_1.fetch)(url, {
47
+ headers: { Accept: "text/event-stream" },
48
+ signal,
49
+ });
50
+ if (!response.ok) {
51
+ const retryable = response.status === 503 || response.status === 502 || response.status === 429;
52
+ if (!retryable || Date.now() - startedAt >= STREAM_RETRY_BUDGET_MS) {
53
+ console.warn("[VisemeSSE] Non-OK response, giving up", { requestId, status: response.status });
54
+ return;
89
55
  }
56
+ console.warn("[VisemeSSE] Retryable error, backing off", { requestId, status: response.status, retryDelay });
57
+ await sleep(retryDelay, signal);
58
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
59
+ continue;
90
60
  }
91
- }
92
- console.log("[VisemeSSE] stream ended", { requestId });
93
- }
94
- catch (err) {
95
- if (err?.name === "AbortError") {
96
- // Expected stream was intentionally cancelled
61
+ const reader = response.body?.getReader();
62
+ if (!reader) {
63
+ console.warn("[VisemeSSE] No response body reader", { requestId });
64
+ return;
65
+ }
66
+ await readSseStream(reader, signal, (payload) => {
67
+ onVisemesRef.current(payload);
68
+ });
69
+ // Stream ended cleanly — done.
70
+ console.log("[VisemeSSE] stream ended", { requestId });
97
71
  return;
98
72
  }
99
- console.warn("[VisemeSSE] stream error", { requestId, err });
100
- }
101
- finally {
102
- // Only clear the ref if it still points to our controller
103
- if (abortControllerRef.current === controller) {
104
- abortControllerRef.current = null;
73
+ catch (err) {
74
+ if (err?.name === "AbortError" || signal.aborted)
75
+ return;
76
+ const elapsed = Date.now() - startedAt;
77
+ if (elapsed >= STREAM_RETRY_BUDGET_MS) {
78
+ console.warn("[VisemeSSE] stream error, retry budget exhausted", { requestId, err });
79
+ return;
80
+ }
81
+ console.warn("[VisemeSSE] stream error, retrying", { requestId, retryDelay, err });
82
+ await sleep(retryDelay, signal);
83
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
105
84
  }
106
85
  }
107
- })();
86
+ })().finally(() => {
87
+ if (abortControllerRef.current === controller) {
88
+ abortControllerRef.current = null;
89
+ }
90
+ });
108
91
  }, []);
109
92
  // Clean up on unmount
110
93
  (0, react_1.useEffect)(() => {
@@ -117,3 +100,86 @@ function useDirectVisemeStream(onVisemes) {
117
100
  }, []);
118
101
  return { openStream };
119
102
  }
103
+ // ─── SSE parser ──────────────────────────────────────────────────────────────
104
+ /**
105
+ * Reads an SSE stream to completion, dispatching `event: visemes` messages.
106
+ *
107
+ * Follows the SSE spec: fields accumulate per-message block; an empty line
108
+ * dispatches the block. Handles streams that end without a trailing newline
109
+ * by flushing the remaining buffer on EOF.
110
+ */
111
+ async function readSseStream(reader, signal, onVisemes) {
112
+ const decoder = new TextDecoder();
113
+ let buffer = "";
114
+ // Per-message accumulator (reset on empty-line dispatch)
115
+ let eventType = null;
116
+ let dataLines = [];
117
+ const dispatchBlock = () => {
118
+ if (eventType === "visemes" && dataLines.length > 0) {
119
+ const jsonText = dataLines.join("\n");
120
+ try {
121
+ const payload = JSON.parse(jsonText);
122
+ console.log("[VisemeSSE] received", {
123
+ requestId: payload.requestId,
124
+ cues: Array.isArray(payload.cues) ? payload.cues.length : 0,
125
+ durationMs: payload.durationMs ?? null,
126
+ });
127
+ onVisemes(payload);
128
+ }
129
+ catch (parseErr) {
130
+ console.warn("[VisemeSSE] JSON parse error", parseErr);
131
+ }
132
+ }
133
+ eventType = null;
134
+ dataLines = [];
135
+ };
136
+ const processLines = (chunk) => {
137
+ const lines = chunk.split("\n");
138
+ for (const rawLine of lines) {
139
+ const line = rawLine.trimEnd();
140
+ if (line === "") {
141
+ // Empty line = end of SSE message block → dispatch
142
+ dispatchBlock();
143
+ }
144
+ else if (line.startsWith("event:")) {
145
+ eventType = line.slice("event:".length).trim();
146
+ }
147
+ else if (line.startsWith("data:")) {
148
+ dataLines.push(line.slice("data:".length).trimStart());
149
+ }
150
+ // Ignore comment lines (":"), id:, retry: fields
151
+ }
152
+ };
153
+ while (!signal.aborted) {
154
+ const { done, value } = await reader.read();
155
+ if (done) {
156
+ // Flush any remaining buffered content without a trailing newline
157
+ if (buffer) {
158
+ processLines(buffer + "\n");
159
+ buffer = "";
160
+ }
161
+ break;
162
+ }
163
+ const chunk = decoder.decode(value, { stream: true });
164
+ // Combine with any previous incomplete line, then split on newlines.
165
+ // Keep the last (potentially incomplete) segment in the buffer.
166
+ const combined = buffer + chunk;
167
+ const lastNewline = combined.lastIndexOf("\n");
168
+ if (lastNewline === -1) {
169
+ buffer = combined;
170
+ continue;
171
+ }
172
+ buffer = combined.slice(lastNewline + 1);
173
+ processLines(combined.slice(0, lastNewline + 1));
174
+ }
175
+ }
176
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
177
+ function sleep(ms, signal) {
178
+ return new Promise((resolve, reject) => {
179
+ const id = setTimeout(resolve, ms);
180
+ signal.addEventListener("abort", () => {
181
+ clearTimeout(id);
182
+ reject(Object.assign(new Error("AbortError"), { name: "AbortError" }));
183
+ }, { once: true });
184
+ });
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-head-studio",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
5
5
  "main": "dist/index.web.js",
6
6
  "browser": "dist/index.web.js",