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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +2 -2
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/next-minimal-server.js.nft.json +1 -1
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/api/beads.body +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +2 -2
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +1 -1
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/page-40b0a256ec8af9de.js +1 -0
- package/.next/static/css/454d96c40653b263.css +3 -0
- package/app/page.tsx +23 -0
- package/components/DescriptionModal.tsx +507 -11
- package/components/HelpPanel.tsx +4 -1
- package/components/NodeDetail.tsx +3 -1
- package/components/SettingsModal.tsx +237 -0
- package/components/TutorialOverlay.tsx +3 -3
- package/lib/settings.ts +42 -0
- package/lib/tts.ts +397 -0
- package/package.json +1 -1
- package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +0 -1
- package/.next/static/css/ade5301262971664.css +0 -3
- /package/.next/static/{JmL0suxsggbSwPxWcmUFV → msGLXabX0J07WQq7roVpb}/_buildManifest.js +0 -0
- /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 & 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,
|
|
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
|
|
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
|
|
package/lib/settings.ts
ADDED
|
@@ -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 
|
|
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
|
+
}
|