heartbeads 0.4.0
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 -0
- package/.next/app-build-manifest.json +49 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- package/tsconfig.json +24 -0
package/lib/timeline.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { GraphNode, GraphLink } from "./types";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Timeline event types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export type TimelineEventType = "node-created" | "node-closed";
|
|
8
|
+
|
|
9
|
+
export interface TimelineEvent {
|
|
10
|
+
time: number; // unix ms
|
|
11
|
+
type: TimelineEventType;
|
|
12
|
+
id: string; // node ID or "source->target"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimelineRange {
|
|
16
|
+
events: TimelineEvent[];
|
|
17
|
+
minTime: number; // earliest event (unix ms)
|
|
18
|
+
maxTime: number; // latest event (unix ms)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Event extraction
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract all temporal events from nodes and links, sorted chronologically.
|
|
27
|
+
*
|
|
28
|
+
* Events:
|
|
29
|
+
* - node-created: from node.createdAt
|
|
30
|
+
* - node-closed: from node.closedAt (if present)
|
|
31
|
+
* - link-created: from link.createdAt (if present)
|
|
32
|
+
*
|
|
33
|
+
* Nodes/links missing timestamps are skipped.
|
|
34
|
+
*/
|
|
35
|
+
export function buildTimelineEvents(
|
|
36
|
+
nodes: GraphNode[],
|
|
37
|
+
links: GraphLink[]
|
|
38
|
+
): TimelineRange {
|
|
39
|
+
const events: TimelineEvent[] = [];
|
|
40
|
+
|
|
41
|
+
for (const node of nodes) {
|
|
42
|
+
const createdMs = new Date(node.createdAt).getTime();
|
|
43
|
+
if (!isNaN(createdMs)) {
|
|
44
|
+
events.push({ time: createdMs, type: "node-created", id: node.id });
|
|
45
|
+
}
|
|
46
|
+
if (node.closedAt) {
|
|
47
|
+
const closedMs = new Date(node.closedAt).getTime();
|
|
48
|
+
if (!isNaN(closedMs)) {
|
|
49
|
+
events.push({ time: closedMs, type: "node-closed", id: node.id });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
events.sort((a, b) => a.time - b.time);
|
|
55
|
+
|
|
56
|
+
const minTime = events.length > 0 ? events[0].time : Date.now();
|
|
57
|
+
const maxTime =
|
|
58
|
+
events.length > 0 ? events[events.length - 1].time : Date.now();
|
|
59
|
+
|
|
60
|
+
return { events, minTime, maxTime };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Time filtering
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Filter nodes and links to only include items visible at `currentTime`.
|
|
69
|
+
*
|
|
70
|
+
* Node visibility: createdAt <= currentTime.
|
|
71
|
+
* Node status override: if closedAt <= currentTime, force status to "closed".
|
|
72
|
+
* Link visibility: both endpoints visible AND link.createdAt <= currentTime.
|
|
73
|
+
* If link has no createdAt, it appears when both endpoints are visible.
|
|
74
|
+
*
|
|
75
|
+
* Returns shallow copies when status is overridden, original objects otherwise
|
|
76
|
+
* (preserves x/y positions from force simulation).
|
|
77
|
+
*/
|
|
78
|
+
export function filterDataAtTime(
|
|
79
|
+
allNodes: GraphNode[],
|
|
80
|
+
allLinks: GraphLink[],
|
|
81
|
+
currentTime: number
|
|
82
|
+
): { nodes: GraphNode[]; links: GraphLink[] } {
|
|
83
|
+
const visibleNodeIds = new Set<string>();
|
|
84
|
+
const nodes: GraphNode[] = [];
|
|
85
|
+
|
|
86
|
+
for (const node of allNodes) {
|
|
87
|
+
const createdMs = new Date(node.createdAt).getTime();
|
|
88
|
+
if (isNaN(createdMs) || createdMs > currentTime) continue;
|
|
89
|
+
|
|
90
|
+
visibleNodeIds.add(node.id);
|
|
91
|
+
|
|
92
|
+
// Determine correct status at this point in time
|
|
93
|
+
let status = node.status;
|
|
94
|
+
if (node.closedAt) {
|
|
95
|
+
const closedMs = new Date(node.closedAt).getTime();
|
|
96
|
+
if (!isNaN(closedMs) && closedMs <= currentTime) {
|
|
97
|
+
status = "closed";
|
|
98
|
+
} else if (node.status === "closed") {
|
|
99
|
+
// Node is closed in current data but we're before closedAt — show as open
|
|
100
|
+
status = "open";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (status !== node.status) {
|
|
105
|
+
nodes.push({ ...node, status } as GraphNode);
|
|
106
|
+
} else {
|
|
107
|
+
nodes.push(node);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter visible links
|
|
112
|
+
const links: GraphLink[] = [];
|
|
113
|
+
for (const link of allLinks) {
|
|
114
|
+
const src =
|
|
115
|
+
typeof link.source === "object"
|
|
116
|
+
? (link.source as { id: string }).id
|
|
117
|
+
: link.source;
|
|
118
|
+
const tgt =
|
|
119
|
+
typeof link.target === "object"
|
|
120
|
+
? (link.target as { id: string }).id
|
|
121
|
+
: link.target;
|
|
122
|
+
|
|
123
|
+
// Both endpoints must be visible — link appears when both nodes are on canvas
|
|
124
|
+
if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;
|
|
125
|
+
|
|
126
|
+
// Normalize source/target to string IDs — d3-force mutates link objects
|
|
127
|
+
// in-place replacing strings with object refs to the main graph's nodes.
|
|
128
|
+
// We must return fresh objects with string IDs so ForceGraph2D resolves
|
|
129
|
+
// them against the timeline's node array, not the main graph's.
|
|
130
|
+
links.push({
|
|
131
|
+
...link,
|
|
132
|
+
source: src,
|
|
133
|
+
target: tgt,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { nodes, links };
|
|
138
|
+
}
|
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
|
+
}
|