beads-map 0.3.4 → 0.3.6

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.
Files changed (35) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +2 -2
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +1 -1
  17. package/.next/server/functions-config-manifest.json +1 -1
  18. package/.next/server/pages/404.html +1 -1
  19. package/.next/server/pages/500.html +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-40b0a256ec8af9de.js +1 -0
  22. package/.next/static/css/454d96c40653b263.css +3 -0
  23. package/app/page.tsx +23 -0
  24. package/components/DescriptionModal.tsx +507 -11
  25. package/components/HelpPanel.tsx +4 -1
  26. package/components/NodeDetail.tsx +3 -1
  27. package/components/SettingsModal.tsx +237 -0
  28. package/components/TutorialOverlay.tsx +3 -3
  29. package/lib/settings.ts +42 -0
  30. package/lib/tts.ts +397 -0
  31. package/package.json +1 -1
  32. package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +0 -1
  33. package/.next/static/css/ade5301262971664.css +0 -3
  34. /package/.next/static/{JmL0suxsggbSwPxWcmUFV → msGLXabX0J07WQq7roVpb}/_buildManifest.js +0 -0
  35. /package/.next/static/{JmL0suxsggbSwPxWcmUFV → msGLXabX0J07WQq7roVpb}/_ssgManifest.js +0 -0
@@ -0,0 +1,237 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import {
6
+ getSettings,
7
+ saveSettings,
8
+ type BeadsMapSettings,
9
+ } from "@/lib/settings";
10
+
11
+ interface SettingsModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ }
15
+
16
+ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
17
+ const [apiKey, setApiKey] = useState("");
18
+ const [voiceId, setVoiceId] = useState("");
19
+ const [model, setModel] = useState("");
20
+ const [showKey, setShowKey] = useState(false);
21
+ const [mounted, setMounted] = useState(false);
22
+
23
+ // SSR guard for createPortal
24
+ useEffect(() => {
25
+ setMounted(true);
26
+ }, []);
27
+
28
+ // Load current settings when modal opens
29
+ useEffect(() => {
30
+ if (isOpen) {
31
+ const s = getSettings();
32
+ setApiKey(s.elevenLabsApiKey || "");
33
+ setVoiceId(s.elevenLabsVoiceId);
34
+ setModel(s.elevenLabsModel);
35
+ setShowKey(false);
36
+ }
37
+ }, [isOpen]);
38
+
39
+ // Escape key
40
+ useEffect(() => {
41
+ if (!isOpen) return;
42
+ const handler = (e: KeyboardEvent) => {
43
+ if (e.key === "Escape") onClose();
44
+ };
45
+ window.addEventListener("keydown", handler);
46
+ return () => window.removeEventListener("keydown", handler);
47
+ }, [isOpen, onClose]);
48
+
49
+ const handleSave = useCallback(() => {
50
+ const updates: Partial<BeadsMapSettings> = {
51
+ elevenLabsApiKey: apiKey.trim() || undefined,
52
+ elevenLabsVoiceId: voiceId.trim() || "UgBBYS2sOqTuMpoF3BR0",
53
+ elevenLabsModel: model || "eleven_turbo_v2_5",
54
+ };
55
+ saveSettings(updates);
56
+ onClose();
57
+ }, [apiKey, voiceId, model, onClose]);
58
+
59
+ if (!isOpen || !mounted) return null;
60
+
61
+ return createPortal(
62
+ <div
63
+ className="fixed inset-0 z-[100] flex items-center justify-center"
64
+ style={{ backgroundColor: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
65
+ onClick={onClose}
66
+ >
67
+ <div
68
+ className="bg-white rounded-xl shadow-2xl w-[90vw] max-w-md flex flex-col"
69
+ onClick={(e) => e.stopPropagation()}
70
+ >
71
+ {/* Header */}
72
+ <div className="flex items-center justify-between px-5 py-3.5 border-b border-zinc-100">
73
+ <h2 className="text-sm font-semibold text-zinc-900">Settings</h2>
74
+ <button
75
+ onClick={onClose}
76
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
77
+ >
78
+ <svg
79
+ className="w-4 h-4"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ viewBox="0 0 24 24"
83
+ strokeWidth={2}
84
+ >
85
+ <path
86
+ strokeLinecap="round"
87
+ strokeLinejoin="round"
88
+ d="M6 18L18 6M6 6l12 12"
89
+ />
90
+ </svg>
91
+ </button>
92
+ </div>
93
+
94
+ {/* Body */}
95
+ <div className="px-5 py-4 space-y-4 overflow-y-auto max-h-[60vh]">
96
+ {/* Section header */}
97
+ <h3 className="text-[11px] font-semibold uppercase tracking-widest text-teal-600">
98
+ Text-to-Speech
99
+ </h3>
100
+
101
+ {/* API Key */}
102
+ <div>
103
+ <label className="block text-xs font-medium text-zinc-700 mb-1">
104
+ ElevenLabs API Key
105
+ </label>
106
+ <div className="relative">
107
+ <input
108
+ type={showKey ? "text" : "password"}
109
+ value={apiKey}
110
+ onChange={(e) => setApiKey(e.target.value)}
111
+ placeholder="sk_..."
112
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 pr-10"
113
+ />
114
+ <button
115
+ type="button"
116
+ onClick={() => setShowKey((v) => !v)}
117
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
118
+ title={showKey ? "Hide key" : "Show key"}
119
+ >
120
+ {showKey ? (
121
+ <svg
122
+ className="w-4 h-4"
123
+ fill="none"
124
+ viewBox="0 0 24 24"
125
+ strokeWidth={1.5}
126
+ stroke="currentColor"
127
+ >
128
+ <path
129
+ strokeLinecap="round"
130
+ strokeLinejoin="round"
131
+ d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
132
+ />
133
+ </svg>
134
+ ) : (
135
+ <svg
136
+ className="w-4 h-4"
137
+ fill="none"
138
+ viewBox="0 0 24 24"
139
+ strokeWidth={1.5}
140
+ stroke="currentColor"
141
+ >
142
+ <path
143
+ strokeLinecap="round"
144
+ strokeLinejoin="round"
145
+ d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
146
+ />
147
+ <path
148
+ strokeLinecap="round"
149
+ strokeLinejoin="round"
150
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
151
+ />
152
+ </svg>
153
+ )}
154
+ </button>
155
+ </div>
156
+ <p className="mt-1 text-[11px] text-zinc-400">
157
+ Get your key at{" "}
158
+ <a
159
+ href="https://elevenlabs.io/app/settings/api-keys"
160
+ target="_blank"
161
+ rel="noopener noreferrer"
162
+ className="underline underline-offset-2 text-teal-500 hover:text-teal-600"
163
+ >
164
+ elevenlabs.io/app/settings
165
+ </a>
166
+ </p>
167
+ </div>
168
+
169
+ {/* Voice ID */}
170
+ <div>
171
+ <label className="block text-xs font-medium text-zinc-700 mb-1">
172
+ Voice ID
173
+ </label>
174
+ <input
175
+ type="text"
176
+ value={voiceId}
177
+ onChange={(e) => setVoiceId(e.target.value)}
178
+ placeholder="UgBBYS2sOqTuMpoF3BR0"
179
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500"
180
+ />
181
+ <p className="mt-1 text-[11px] text-zinc-400">
182
+ Default: Mark - Natural Conversations. Find voice IDs in the{" "}
183
+ <a
184
+ href="https://elevenlabs.io/app/voice-library"
185
+ target="_blank"
186
+ rel="noopener noreferrer"
187
+ className="underline underline-offset-2 text-teal-500 hover:text-teal-600"
188
+ >
189
+ ElevenLabs Voice Library
190
+ </a>
191
+ .
192
+ </p>
193
+ </div>
194
+
195
+ {/* Model */}
196
+ <div>
197
+ <label className="block text-xs font-medium text-zinc-700 mb-1">
198
+ Model
199
+ </label>
200
+ <select
201
+ value={model}
202
+ onChange={(e) => setModel(e.target.value)}
203
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-900 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500"
204
+ >
205
+ <option value="eleven_turbo_v2_5">
206
+ Turbo v2.5 (default, fast &amp; cheap)
207
+ </option>
208
+ <option value="eleven_flash_v2_5">
209
+ Flash v2.5 (fastest, cheapest)
210
+ </option>
211
+ <option value="eleven_multilingual_v2">
212
+ Multilingual v2 (highest quality)
213
+ </option>
214
+ </select>
215
+ </div>
216
+ </div>
217
+
218
+ {/* Footer */}
219
+ <div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-zinc-100">
220
+ <button
221
+ onClick={onClose}
222
+ className="px-4 py-2 text-sm font-medium text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-50 transition-colors"
223
+ >
224
+ Cancel
225
+ </button>
226
+ <button
227
+ onClick={handleSave}
228
+ className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors"
229
+ >
230
+ Save
231
+ </button>
232
+ </div>
233
+ </div>
234
+ </div>,
235
+ document.body
236
+ );
237
+ }
@@ -27,7 +27,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
27
27
  target: "view-controls",
28
28
  title: "View Controls",
29
29
  description:
30
- "Collapse or expand epic groups, toggle cluster label overlays, and control auto-fit. When auto-fit is on (green), the camera re-centers after every update. Turn it off to stay focused on a specific area while data streams in.",
30
+ "Collapse or expand epic groups, toggle cluster label overlays, control auto-fit, and toggle the pulse animation. When auto-fit is on (green), the camera re-centers after every update. Pulse highlights the most recently active node with emerald ripples.",
31
31
  },
32
32
  {
33
33
  target: "legend",
@@ -51,14 +51,14 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
51
51
  target: "graph",
52
52
  title: "Interacting with Nodes",
53
53
  description:
54
- "Click a node to see details. Hover for a quick tooltip. Right-click for actions like viewing descriptions, commenting, claiming tasks, or collapsing epics.",
54
+ "Click a node to see details. Hover for a quick tooltip. Right-click for actions like viewing descriptions, commenting, claiming tasks, collapsing epics, or focusing on an epic to isolate its subgraph.",
55
55
  padding: 0,
56
56
  },
57
57
  {
58
58
  target: "nav-pills",
59
59
  title: "Navigation Bar",
60
60
  description:
61
- "Replay steps through your project\u2019s history. Comments shows conversations. Activity is a real-time feed. And Learn brings you right back here.",
61
+ "Replay steps through your project\u2019s history. Comments shows conversations across your beads. Activity is a real-time feed filtered to only your local issues. And Learn brings you right back here.",
62
62
  },
63
63
  ];
64
64
 
@@ -0,0 +1,42 @@
1
+ // ============================================================================
2
+ // User settings — persisted in localStorage
3
+ // ============================================================================
4
+
5
+ export interface BeadsMapSettings {
6
+ elevenLabsApiKey?: string;
7
+ elevenLabsVoiceId: string;
8
+ elevenLabsModel: string;
9
+ }
10
+
11
+ const STORAGE_KEY = "beads-map-settings";
12
+
13
+ const DEFAULTS: BeadsMapSettings = {
14
+ elevenLabsVoiceId: "UgBBYS2sOqTuMpoF3BR0", // Mark - Natural Conversations
15
+ elevenLabsModel: "eleven_turbo_v2_5",
16
+ };
17
+
18
+ /** Read settings from localStorage, merged with defaults */
19
+ export function getSettings(): BeadsMapSettings {
20
+ if (typeof window === "undefined") return { ...DEFAULTS };
21
+ try {
22
+ const raw = localStorage.getItem(STORAGE_KEY);
23
+ if (!raw) return { ...DEFAULTS };
24
+ return { ...DEFAULTS, ...JSON.parse(raw) };
25
+ } catch {
26
+ return { ...DEFAULTS };
27
+ }
28
+ }
29
+
30
+ /** Write settings to localStorage */
31
+ export function saveSettings(settings: Partial<BeadsMapSettings>): void {
32
+ if (typeof window === "undefined") return;
33
+ const current = getSettings();
34
+ const merged = { ...current, ...settings };
35
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
36
+ }
37
+
38
+ /** Check if ElevenLabs API key is configured */
39
+ export function hasApiKey(): boolean {
40
+ const s = getSettings();
41
+ return !!s.elevenLabsApiKey && s.elevenLabsApiKey.trim().length > 0;
42
+ }
package/lib/tts.ts ADDED
@@ -0,0 +1,397 @@
1
+ // ============================================================================
2
+ // ElevenLabs Text-to-Speech helper
3
+ //
4
+ // Uses the /with-timestamps API endpoint to get character-level alignment data
5
+ // alongside audio. This enables zero-API-call text-selection playback by
6
+ // seeking within cached full-text audio.
7
+ // ============================================================================
8
+
9
+ import { getSettings } from "./settings";
10
+
11
+ // --- Markdown stripping ---------------------------------------------------
12
+
13
+ /** Strip Markdown syntax to produce clean plain text for TTS */
14
+ export function stripMarkdown(md: string): string {
15
+ return (
16
+ md
17
+ // Remove code blocks (triple backtick)
18
+ .replace(/```[\s\S]*?```/g, "")
19
+ // Remove inline code
20
+ .replace(/`([^`]+)`/g, "$1")
21
+ // Remove images ![alt](url)
22
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
23
+ // Convert links [text](url) to just text
24
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
25
+ // Remove headers (# ## ### etc)
26
+ .replace(/^#{1,6}\s+/gm, "")
27
+ // Remove bold/italic markers
28
+ .replace(/(\*{1,3}|_{1,3})(.*?)\1/g, "$2")
29
+ // Remove strikethrough
30
+ .replace(/~~(.*?)~~/g, "$1")
31
+ // Remove horizontal rules
32
+ .replace(/^[-*_]{3,}\s*$/gm, "")
33
+ // Remove blockquotes
34
+ .replace(/^>\s+/gm, "")
35
+ // Remove list markers
36
+ .replace(/^[\s]*[-*+]\s+/gm, "")
37
+ .replace(/^[\s]*\d+\.\s+/gm, "")
38
+ // Remove HTML tags
39
+ .replace(/<[^>]+>/g, "")
40
+ // Collapse multiple newlines
41
+ .replace(/\n{3,}/g, "\n\n")
42
+ .trim()
43
+ );
44
+ }
45
+
46
+ // --- Types ----------------------------------------------------------------
47
+
48
+ /** Character-level alignment from ElevenLabs with-timestamps API */
49
+ export interface CharacterAlignment {
50
+ characters: string[];
51
+ characterStartTimesSeconds: number[];
52
+ characterEndTimesSeconds: number[];
53
+ }
54
+
55
+ /** Cached audio entry with alignment data for seek-based selection playback */
56
+ export interface CachedAudio {
57
+ blob: Blob;
58
+ alignment: CharacterAlignment;
59
+ strippedText: string; // exact text sent to ElevenLabs
60
+ }
61
+
62
+ export type TtsState = "idle" | "loading" | "playing" | "paused" | "error";
63
+
64
+ // --- Audio cache (session-scoped, LRU, max 10 entries) --------------------
65
+
66
+ const audioCache = new Map<string, CachedAudio>();
67
+ const CACHE_MAX = 10;
68
+
69
+ function cacheKey(voiceId: string, model: string, text: string): string {
70
+ return `${voiceId}:${model}:${text}`;
71
+ }
72
+
73
+ function cacheGet(key: string): CachedAudio | undefined {
74
+ const entry = audioCache.get(key);
75
+ if (entry) {
76
+ // LRU: move to end by delete + re-insert
77
+ audioCache.delete(key);
78
+ audioCache.set(key, entry);
79
+ }
80
+ return entry;
81
+ }
82
+
83
+ function cachePut(key: string, entry: CachedAudio): void {
84
+ if (audioCache.size >= CACHE_MAX) {
85
+ // Evict oldest (first key in Map iteration order)
86
+ const oldest = audioCache.keys().next().value;
87
+ if (oldest) audioCache.delete(oldest);
88
+ }
89
+ audioCache.set(key, entry);
90
+ }
91
+
92
+ // --- Substring alignment lookup -------------------------------------------
93
+
94
+ /** Normalize whitespace for fuzzy substring matching */
95
+ function normalizeWhitespace(s: string): string {
96
+ return s.replace(/\s+/g, " ").trim().toLowerCase();
97
+ }
98
+
99
+ /**
100
+ * Check if any cached audio contains the given text as a substring.
101
+ * If found, returns the blob and start/end times for seeking.
102
+ * This enables zero-API-call playback for text selections.
103
+ */
104
+ export function findCachedAlignmentForText(selectedText: string): {
105
+ blob: Blob;
106
+ startTime: number;
107
+ endTime: number;
108
+ } | null {
109
+ const normalizedSelection = normalizeWhitespace(selectedText);
110
+ if (!normalizedSelection) return null;
111
+
112
+ for (const entry of audioCache.values()) {
113
+ const normalizedFull = normalizeWhitespace(entry.strippedText);
114
+ const idx = normalizedFull.indexOf(normalizedSelection);
115
+ if (idx === -1) continue;
116
+
117
+ // Build a mapping from each char in strippedText to its position in the
118
+ // normalized (lowercased, collapsed-whitespace) version.
119
+ const { strippedText, alignment } = entry;
120
+ const charToNormPos: number[] = [];
121
+ let np = 0;
122
+ let prevWasSpace = false;
123
+
124
+ for (let i = 0; i < strippedText.length; i++) {
125
+ const isSpace = /\s/.test(strippedText[i]);
126
+ if (isSpace) {
127
+ if (!prevWasSpace && np > 0) {
128
+ charToNormPos.push(np);
129
+ np++;
130
+ } else {
131
+ charToNormPos.push(-1); // collapsed away
132
+ }
133
+ prevWasSpace = true;
134
+ } else {
135
+ charToNormPos.push(np);
136
+ np++;
137
+ prevWasSpace = false;
138
+ }
139
+ }
140
+
141
+ // Find the strippedText char indices that map to the normalized range
142
+ const selEnd = idx + normalizedSelection.length - 1;
143
+ let startCharIdx = -1;
144
+ let endCharIdx = -1;
145
+
146
+ for (let i = 0; i < charToNormPos.length; i++) {
147
+ if (charToNormPos[i] === idx && startCharIdx === -1) {
148
+ startCharIdx = i;
149
+ }
150
+ if (charToNormPos[i] === selEnd) {
151
+ endCharIdx = i;
152
+ }
153
+ }
154
+
155
+ if (startCharIdx === -1 || endCharIdx === -1) continue;
156
+
157
+ // Clamp to alignment array bounds
158
+ const alignLen = alignment.characterStartTimesSeconds.length;
159
+ if (alignLen === 0) continue;
160
+
161
+ const clampedStart = Math.min(startCharIdx, alignLen - 1);
162
+ const clampedEnd = Math.min(endCharIdx, alignment.characterEndTimesSeconds.length - 1);
163
+
164
+ if (clampedStart < 0 || clampedEnd < 0) continue;
165
+
166
+ return {
167
+ blob: entry.blob,
168
+ startTime: alignment.characterStartTimesSeconds[clampedStart],
169
+ endTime: alignment.characterEndTimesSeconds[clampedEnd],
170
+ };
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ // --- Audio playback state -------------------------------------------------
177
+
178
+ /** Current audio element for stop control */
179
+ let currentAudio: HTMLAudioElement | null = null;
180
+ let currentBlobUrl: string | null = null;
181
+
182
+ /** When playing a selection from cached audio, auto-stop at this time */
183
+ let selectionEndTime: number | null = null;
184
+
185
+ // --- Playback controls ----------------------------------------------------
186
+
187
+ /** Set playback speed on the current audio element */
188
+ export function setTtsPlaybackRate(rate: number): void {
189
+ if (currentAudio) {
190
+ currentAudio.playbackRate = rate;
191
+ }
192
+ }
193
+
194
+ /** Pause TTS audio without destroying — preserves position for resume */
195
+ export function pauseTts(): void {
196
+ if (currentAudio && !currentAudio.paused) {
197
+ currentAudio.pause();
198
+ }
199
+ }
200
+
201
+ /** Resume paused TTS audio from where it left off */
202
+ export function resumeTts(): void {
203
+ if (currentAudio && currentAudio.paused && currentAudio.currentTime > 0) {
204
+ currentAudio.play();
205
+ }
206
+ }
207
+
208
+ /** Stop any currently playing TTS audio */
209
+ export function stopTts(): void {
210
+ if (currentAudio) {
211
+ currentAudio.pause();
212
+ currentAudio.currentTime = 0;
213
+ currentAudio.onended = null;
214
+ currentAudio.onerror = null;
215
+ currentAudio.ontimeupdate = null;
216
+ currentAudio = null;
217
+ }
218
+ if (currentBlobUrl) {
219
+ URL.revokeObjectURL(currentBlobUrl);
220
+ currentBlobUrl = null;
221
+ }
222
+ selectionEndTime = null;
223
+ }
224
+
225
+ // --- Internal: create Audio from blob and play ----------------------------
226
+
227
+ /** Internal: create Audio from blob and play it */
228
+ function playBlobAsAudio(
229
+ blob: Blob,
230
+ onStateChange: (state: TtsState, error?: string) => void,
231
+ ): void {
232
+ // Full-text playback: no auto-stop
233
+ selectionEndTime = null;
234
+
235
+ const blobUrl = URL.createObjectURL(blob);
236
+ currentBlobUrl = blobUrl;
237
+
238
+ const audio = new Audio(blobUrl);
239
+ currentAudio = audio;
240
+
241
+ onStateChange("playing");
242
+
243
+ audio.onended = () => {
244
+ stopTts();
245
+ onStateChange("idle");
246
+ };
247
+
248
+ audio.onerror = () => {
249
+ stopTts();
250
+ onStateChange("error", "Audio playback failed");
251
+ };
252
+
253
+ audio.play();
254
+ }
255
+
256
+ // --- Main TTS functions ---------------------------------------------------
257
+
258
+ /**
259
+ * Call ElevenLabs TTS API (with-timestamps) and play the result.
260
+ * Uses cache to avoid redundant API calls.
261
+ *
262
+ * @param text - Plain text to speak (already stripped of Markdown)
263
+ * @param onStateChange - Callback for state transitions
264
+ */
265
+ export async function speakWithElevenLabs(
266
+ text: string,
267
+ onStateChange: (state: TtsState, error?: string) => void,
268
+ ): Promise<void> {
269
+ // Stop any existing playback
270
+ stopTts();
271
+
272
+ const settings = getSettings();
273
+ if (!settings.elevenLabsApiKey) {
274
+ onStateChange("error", "No API key configured");
275
+ return;
276
+ }
277
+
278
+ const voiceId = settings.elevenLabsVoiceId;
279
+ const key = cacheKey(voiceId, settings.elevenLabsModel, text);
280
+
281
+ // Check cache first — zero API call on hit
282
+ const cached = cacheGet(key);
283
+ if (cached) {
284
+ playBlobAsAudio(cached.blob, onStateChange);
285
+ return;
286
+ }
287
+
288
+ onStateChange("loading");
289
+
290
+ try {
291
+ const res = await fetch(
292
+ `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps?output_format=mp3_44100_128`,
293
+ {
294
+ method: "POST",
295
+ headers: {
296
+ "Content-Type": "application/json",
297
+ "xi-api-key": settings.elevenLabsApiKey,
298
+ },
299
+ body: JSON.stringify({
300
+ text,
301
+ model_id: settings.elevenLabsModel,
302
+ }),
303
+ },
304
+ );
305
+
306
+ if (!res.ok) {
307
+ const errorText = await res.text().catch(() => res.statusText);
308
+ throw new Error(`ElevenLabs API error (${res.status}): ${errorText}`);
309
+ }
310
+
311
+ const json = await res.json();
312
+ const { audio_base64, alignment } = json;
313
+
314
+ // Decode base64 to Blob
315
+ const binary = atob(audio_base64);
316
+ const bytes = new Uint8Array(binary.length);
317
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
318
+ const blob = new Blob([bytes], { type: "audio/mpeg" });
319
+
320
+ // Store in cache with alignment data
321
+ const cachedEntry: CachedAudio = {
322
+ blob,
323
+ alignment: {
324
+ characters: alignment.characters,
325
+ characterStartTimesSeconds: alignment.character_start_times_seconds,
326
+ characterEndTimesSeconds: alignment.character_end_times_seconds,
327
+ },
328
+ strippedText: text,
329
+ };
330
+ cachePut(key, cachedEntry);
331
+
332
+ playBlobAsAudio(blob, onStateChange);
333
+ } catch (err) {
334
+ stopTts();
335
+ const message = err instanceof Error ? err.message : "TTS failed";
336
+ onStateChange("error", message);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Play a text selection, leveraging cached audio when possible.
342
+ *
343
+ * Credit-saving flow:
344
+ * 1. Check if any cached full-text audio contains this selection as a substring
345
+ * 2. If yes: create Audio from cached blob, seek to start time, auto-stop at end time
346
+ * 3. If no: fall back to speakWithElevenLabs() (makes API call, result gets cached)
347
+ *
348
+ * @param selectedText - The raw text the user selected (from window.getSelection().toString())
349
+ * @param onStateChange - State change callback (same as speakWithElevenLabs)
350
+ */
351
+ export async function speakSelection(
352
+ selectedText: string,
353
+ onStateChange: (state: TtsState, error?: string) => void,
354
+ ): Promise<void> {
355
+ // Stop any existing playback first
356
+ stopTts();
357
+
358
+ // Try zero-API-call path: seek within cached full-text audio
359
+ const cached = findCachedAlignmentForText(selectedText);
360
+ if (cached) {
361
+ const { blob, startTime, endTime } = cached;
362
+ const blobUrl = URL.createObjectURL(blob);
363
+ currentBlobUrl = blobUrl;
364
+
365
+ const audio = new Audio(blobUrl);
366
+ currentAudio = audio;
367
+ selectionEndTime = endTime;
368
+
369
+ audio.onended = () => {
370
+ stopTts();
371
+ onStateChange("idle");
372
+ };
373
+
374
+ audio.onerror = () => {
375
+ stopTts();
376
+ onStateChange("error", "Audio playback failed");
377
+ };
378
+
379
+ // Auto-stop at selection end time
380
+ audio.ontimeupdate = () => {
381
+ if (selectionEndTime !== null && audio.currentTime >= selectionEndTime) {
382
+ stopTts();
383
+ onStateChange("idle");
384
+ }
385
+ };
386
+
387
+ // Seek to selection start, then play
388
+ audio.currentTime = startTime;
389
+ onStateChange("playing");
390
+ await audio.play();
391
+ return;
392
+ }
393
+
394
+ // Cache miss: fall back to full API call for just the selected text
395
+ // This result also gets cached (with its own alignment data) for future replays
396
+ await speakWithElevenLabs(selectedText, onStateChange);
397
+ }