Wothmag07 commited on
Commit
888b792
·
1 Parent(s): a1e4786

Voice module integration in noteviewer

Browse files
.env.example CHANGED
@@ -2,3 +2,6 @@ JWT_SECRET_KEY=change-me
2
  HF_OAUTH_CLIENT_ID=your-hf-client-id
3
  HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
4
  VAULT_BASE_PATH=./data/vaults
 
 
 
 
2
  HF_OAUTH_CLIENT_ID=your-hf-client-id
3
  HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
4
  VAULT_BASE_PATH=./data/vaults
5
+ ELEVENLABS_API_KEY=your-elevenlabs-api-key
6
+ ELEVENLABS_VOICE_ID=your-elevenlabs-voice-id
7
+ ELEVENLABS_MODEL=eleven_multilingual_v2
backend/.env.example CHANGED
@@ -1,4 +1,7 @@
1
  JWT_SECRET_KEY=your-secret-key-here
2
  HF_OAUTH_CLIENT_ID=your-hf-client-id
3
  HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
4
- VAULT_BASE_PATH=./data/vaults
 
 
 
 
1
  JWT_SECRET_KEY=your-secret-key-here
2
  HF_OAUTH_CLIENT_ID=your-hf-client-id
3
  HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
4
+ VAULT_BASE_PATH=./data/vaults
5
+ ELEVENLABS_API_KEY=your-elevenlabs-api-key
6
+ ELEVENLABS_VOICE_ID=your-elevenlabs-voice-id
7
+ ELEVENLABS_MODEL=eleven_multilingual_v2
backend/src/api/main.py CHANGED
@@ -21,7 +21,7 @@ from starlette.responses import Response
21
  from fastmcp.server.http import StreamableHTTPSessionManager, set_http_request
22
  from fastapi.responses import FileResponse
23
 
24
- from .routes import auth, index, notes, search, graph, demo, system, rag
25
  from ..mcp.server import mcp
26
  from ..services.seed import init_and_seed
27
  from ..services.config import get_config
@@ -113,6 +113,7 @@ app.include_router(graph.router, tags=["graph"])
113
  app.include_router(demo.router, tags=["demo"])
114
  app.include_router(system.router, tags=["system"])
115
  app.include_router(rag.router, tags=["rag"])
 
116
 
117
 
118
  @app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
 
21
  from fastmcp.server.http import StreamableHTTPSessionManager, set_http_request
22
  from fastapi.responses import FileResponse
23
 
24
+ from .routes import auth, index, notes, search, graph, demo, system, rag, tts
25
  from ..mcp.server import mcp
26
  from ..services.seed import init_and_seed
27
  from ..services.config import get_config
 
113
  app.include_router(demo.router, tags=["demo"])
114
  app.include_router(system.router, tags=["system"])
115
  app.include_router(rag.router, tags=["rag"])
116
+ app.include_router(tts.router, tags=["tts"])
117
 
118
 
119
  @app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
backend/src/api/routes/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
  """HTTP API route handlers."""
2
 
3
- from . import auth, index, notes, search, graph, demo
4
 
5
- __all__ = ["auth", "notes", "search", "index", "graph", "demo"]
 
1
  """HTTP API route handlers."""
2
 
3
+ from . import auth, index, notes, search, graph, demo, tts
4
 
5
+ __all__ = ["auth", "notes", "search", "index", "graph", "demo", "tts"]
backend/src/api/routes/tts.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP API routes for ElevenLabs text-to-speech."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import httpx
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from fastapi.responses import Response
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ..middleware import AuthContext, get_auth_context
13
+
14
+ router = APIRouter()
15
+
16
+ ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1/text-to-speech"
17
+ DEFAULT_MODEL = "eleven_multilingual_v2"
18
+ # ElevenLabs docs mention a hard limit; keep a conservative cap for safety.
19
+ MAX_TEXT_LENGTH = 4800
20
+
21
+
22
+ class TtsRequest(BaseModel):
23
+ """Payload for synthesizing speech."""
24
+
25
+ text: str = Field(..., min_length=1, description="Plaintext to convert to speech")
26
+ voice_id: str | None = Field(
27
+ default=None,
28
+ description="Override voice id; falls back to ELEVENLABS_VOICE_ID",
29
+ )
30
+ model: str | None = Field(
31
+ default=None,
32
+ description="Model override; defaults to ELEVENLABS_MODEL or a safe default",
33
+ )
34
+
35
+
36
+ async def _call_elevenlabs(
37
+ api_key: str, voice_id: str, model: str, text: str
38
+ ) -> httpx.Response:
39
+ """Invoke ElevenLabs TTS API and return the raw response."""
40
+ headers = {
41
+ "xi-api-key": api_key,
42
+ "Accept": "audio/mpeg",
43
+ }
44
+ payload = {
45
+ "text": text,
46
+ "model_id": model,
47
+ }
48
+ async with httpx.AsyncClient(timeout=30.0) as client:
49
+ return await client.post(
50
+ f"{ELEVENLABS_API_URL}/{voice_id}",
51
+ headers=headers,
52
+ json=payload,
53
+ )
54
+
55
+
56
+ @router.post("/api/tts")
57
+ async def synthesize_tts(
58
+ payload: TtsRequest, auth: AuthContext = Depends(get_auth_context)
59
+ ):
60
+ """Synthesize speech for the provided text using ElevenLabs."""
61
+ api_key = os.getenv("ELEVENLABS_API_KEY")
62
+ default_voice = os.getenv("ELEVENLABS_VOICE_ID")
63
+ default_model = os.getenv("ELEVENLABS_MODEL") or DEFAULT_MODEL
64
+
65
+ if not api_key:
66
+ raise HTTPException(
67
+ status_code=500,
68
+ detail={
69
+ "error": "tts_not_configured",
70
+ "message": "ELEVENLABS_API_KEY is not set on the server.",
71
+ },
72
+ )
73
+
74
+ voice_id = payload.voice_id or default_voice
75
+ if not voice_id:
76
+ raise HTTPException(
77
+ status_code=400,
78
+ detail={
79
+ "error": "voice_required",
80
+ "message": "Voice ID is required. Set ELEVENLABS_VOICE_ID or pass voice_id in the request.",
81
+ },
82
+ )
83
+
84
+ text = (payload.text or "").strip()
85
+ if not text:
86
+ raise HTTPException(
87
+ status_code=400,
88
+ detail={
89
+ "error": "empty_text",
90
+ "message": "Text is empty.",
91
+ },
92
+ )
93
+
94
+ if len(text) > MAX_TEXT_LENGTH:
95
+ text = text[:MAX_TEXT_LENGTH]
96
+
97
+ try:
98
+ response = await _call_elevenlabs(
99
+ api_key, voice_id, payload.model or default_model, text
100
+ )
101
+ except httpx.TimeoutException as exc:
102
+ raise HTTPException(
103
+ status_code=504,
104
+ detail={
105
+ "error": "tts_timeout",
106
+ "message": "ElevenLabs request timed out.",
107
+ },
108
+ ) from exc
109
+ except httpx.HTTPError as exc:
110
+ raise HTTPException(
111
+ status_code=502,
112
+ detail={
113
+ "error": "tts_http_error",
114
+ "message": f"ElevenLabs request failed: {str(exc)}",
115
+ },
116
+ ) from exc
117
+
118
+ if response.status_code >= 400:
119
+ try:
120
+ error_payload = response.json()
121
+ message = (
122
+ error_payload.get("detail")
123
+ or error_payload.get("message")
124
+ or "Failed to synthesize speech."
125
+ )
126
+ except Exception:
127
+ message = response.text[:200] or "Failed to synthesize speech."
128
+
129
+ raise HTTPException(
130
+ status_code=response.status_code,
131
+ detail={
132
+ "error": "tts_failed",
133
+ "message": message,
134
+ },
135
+ )
136
+
137
+ return Response(
138
+ content=response.content,
139
+ media_type="audio/mpeg",
140
+ headers={"Cache-Control": "no-store"},
141
+ )
frontend/package-lock.json CHANGED
@@ -1,12 +1,13 @@
1
  {
2
- "name": "frontend",
3
- "version": "0.0.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
- "name": "frontend",
9
- "version": "0.0.0",
 
10
  "dependencies": {
11
  "@radix-ui/react-avatar": "^1.1.11",
12
  "@radix-ui/react-collapsible": "^1.1.12",
@@ -122,7 +123,6 @@
122
  "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
123
  "dev": true,
124
  "license": "MIT",
125
- "peer": true,
126
  "dependencies": {
127
  "@babel/code-frame": "^7.27.1",
128
  "@babel/generator": "^7.28.5",
@@ -714,7 +714,6 @@
714
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
715
  "dev": true,
716
  "license": "MIT",
717
- "peer": true,
718
  "engines": {
719
  "node": ">=12"
720
  },
@@ -1740,7 +1739,6 @@
1740
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
1741
  "dev": true,
1742
  "license": "MIT",
1743
- "peer": true,
1744
  "engines": {
1745
  "node": "^14.21.3 || >=16"
1746
  },
@@ -3341,7 +3339,6 @@
3341
  "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
3342
  "devOptional": true,
3343
  "license": "MIT",
3344
- "peer": true,
3345
  "dependencies": {
3346
  "undici-types": "~7.16.0"
3347
  }
@@ -3351,7 +3348,6 @@
3351
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
3352
  "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==",
3353
  "license": "MIT",
3354
- "peer": true,
3355
  "dependencies": {
3356
  "csstype": "^3.0.2"
3357
  }
@@ -3362,7 +3358,6 @@
3362
  "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
3363
  "devOptional": true,
3364
  "license": "MIT",
3365
- "peer": true,
3366
  "peerDependencies": {
3367
  "@types/react": "^19.2.0"
3368
  }
@@ -3426,7 +3421,6 @@
3426
  "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
3427
  "dev": true,
3428
  "license": "MIT",
3429
- "peer": true,
3430
  "dependencies": {
3431
  "@typescript-eslint/scope-manager": "8.46.4",
3432
  "@typescript-eslint/types": "8.46.4",
@@ -3708,7 +3702,6 @@
3708
  "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
3709
  "dev": true,
3710
  "license": "MIT",
3711
- "peer": true,
3712
  "bin": {
3713
  "acorn": "bin/acorn"
3714
  },
@@ -3976,24 +3969,28 @@
3976
  }
3977
  },
3978
  "node_modules/body-parser": {
3979
- "version": "2.2.0",
3980
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
3981
- "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
3982
  "dev": true,
3983
  "license": "MIT",
3984
  "dependencies": {
3985
  "bytes": "^3.1.2",
3986
  "content-type": "^1.0.5",
3987
- "debug": "^4.4.0",
3988
  "http-errors": "^2.0.0",
3989
- "iconv-lite": "^0.6.3",
3990
  "on-finished": "^2.4.1",
3991
  "qs": "^6.14.0",
3992
- "raw-body": "^3.0.0",
3993
- "type-is": "^2.0.0"
3994
  },
3995
  "engines": {
3996
  "node": ">=18"
 
 
 
 
3997
  }
3998
  },
3999
  "node_modules/brace-expansion": {
@@ -4039,7 +4036,6 @@
4039
  }
4040
  ],
4041
  "license": "MIT",
4042
- "peer": true,
4043
  "dependencies": {
4044
  "baseline-browser-mapping": "^2.8.25",
4045
  "caniuse-lite": "^1.0.30001754",
@@ -4739,7 +4735,6 @@
4739
  "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
4740
  "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
4741
  "license": "ISC",
4742
- "peer": true,
4743
  "engines": {
4744
  "node": ">=12"
4745
  }
@@ -5157,7 +5152,6 @@
5157
  "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
5158
  "dev": true,
5159
  "license": "MIT",
5160
- "peer": true,
5161
  "dependencies": {
5162
  "@eslint-community/eslint-utils": "^4.8.0",
5163
  "@eslint-community/regexpp": "^4.12.1",
@@ -5426,7 +5420,6 @@
5426
  "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
5427
  "dev": true,
5428
  "license": "MIT",
5429
- "peer": true,
5430
  "dependencies": {
5431
  "accepts": "^2.0.0",
5432
  "body-parser": "^2.2.0",
@@ -6215,9 +6208,9 @@
6215
  }
6216
  },
6217
  "node_modules/iconv-lite": {
6218
- "version": "0.6.3",
6219
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
6220
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
6221
  "dev": true,
6222
  "license": "MIT",
6223
  "dependencies": {
@@ -6225,6 +6218,10 @@
6225
  },
6226
  "engines": {
6227
  "node": ">=0.10.0"
 
 
 
 
6228
  }
6229
  },
6230
  "node_modules/ignore": {
@@ -6548,7 +6545,6 @@
6548
  "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
6549
  "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
6550
  "license": "MIT",
6551
- "peer": true,
6552
  "bin": {
6553
  "jiti": "bin/jiti.js"
6554
  }
@@ -8418,7 +8414,6 @@
8418
  }
8419
  ],
8420
  "license": "MIT",
8421
- "peer": true,
8422
  "dependencies": {
8423
  "nanoid": "^3.3.11",
8424
  "picocolors": "^1.1.1",
@@ -8729,29 +8724,11 @@
8729
  "node": ">= 0.10"
8730
  }
8731
  },
8732
- "node_modules/raw-body/node_modules/iconv-lite": {
8733
- "version": "0.7.0",
8734
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
8735
- "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
8736
- "dev": true,
8737
- "license": "MIT",
8738
- "dependencies": {
8739
- "safer-buffer": ">= 2.1.2 < 3.0.0"
8740
- },
8741
- "engines": {
8742
- "node": ">=0.10.0"
8743
- },
8744
- "funding": {
8745
- "type": "opencollective",
8746
- "url": "https://opencollective.com/express"
8747
- }
8748
- },
8749
  "node_modules/react": {
8750
  "version": "19.2.0",
8751
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
8752
  "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
8753
  "license": "MIT",
8754
- "peer": true,
8755
  "engines": {
8756
  "node": ">=0.10.0"
8757
  }
@@ -8761,7 +8738,6 @@
8761
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
8762
  "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
8763
  "license": "MIT",
8764
- "peer": true,
8765
  "dependencies": {
8766
  "scheduler": "^0.27.0"
8767
  },
@@ -9852,7 +9828,6 @@
9852
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
9853
  "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
9854
  "license": "MIT",
9855
- "peer": true,
9856
  "dependencies": {
9857
  "@alloc/quick-lru": "^5.2.0",
9858
  "arg": "^5.0.2",
@@ -9990,7 +9965,6 @@
9990
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
9991
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
9992
  "license": "MIT",
9993
- "peer": true,
9994
  "engines": {
9995
  "node": ">=12"
9996
  },
@@ -10170,7 +10144,6 @@
10170
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
10171
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
10172
  "license": "Apache-2.0",
10173
- "peer": true,
10174
  "bin": {
10175
  "tsc": "bin/tsc",
10176
  "tsserver": "bin/tsserver"
@@ -10573,7 +10546,6 @@
10573
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
10574
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
10575
  "license": "MIT",
10576
- "peer": true,
10577
  "engines": {
10578
  "node": ">=12"
10579
  },
@@ -10764,7 +10736,6 @@
10764
  "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
10765
  "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
10766
  "license": "ISC",
10767
- "peer": true,
10768
  "bin": {
10769
  "yaml": "bin.mjs"
10770
  },
@@ -10891,7 +10862,6 @@
10891
  "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
10892
  "dev": true,
10893
  "license": "MIT",
10894
- "peer": true,
10895
  "funding": {
10896
  "url": "https://github.com/sponsors/colinhacks"
10897
  }
 
1
  {
2
+ "name": "document-mcp-frontend",
3
+ "version": "0.1.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
+ "name": "document-mcp-frontend",
9
+ "version": "0.1.0",
10
+ "license": "MIT",
11
  "dependencies": {
12
  "@radix-ui/react-avatar": "^1.1.11",
13
  "@radix-ui/react-collapsible": "^1.1.12",
 
123
  "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
124
  "dev": true,
125
  "license": "MIT",
 
126
  "dependencies": {
127
  "@babel/code-frame": "^7.27.1",
128
  "@babel/generator": "^7.28.5",
 
714
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
715
  "dev": true,
716
  "license": "MIT",
 
717
  "engines": {
718
  "node": ">=12"
719
  },
 
1739
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
1740
  "dev": true,
1741
  "license": "MIT",
 
1742
  "engines": {
1743
  "node": "^14.21.3 || >=16"
1744
  },
 
3339
  "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
3340
  "devOptional": true,
3341
  "license": "MIT",
 
3342
  "dependencies": {
3343
  "undici-types": "~7.16.0"
3344
  }
 
3348
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
3349
  "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==",
3350
  "license": "MIT",
 
3351
  "dependencies": {
3352
  "csstype": "^3.0.2"
3353
  }
 
3358
  "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
3359
  "devOptional": true,
3360
  "license": "MIT",
 
3361
  "peerDependencies": {
3362
  "@types/react": "^19.2.0"
3363
  }
 
3421
  "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
3422
  "dev": true,
3423
  "license": "MIT",
 
3424
  "dependencies": {
3425
  "@typescript-eslint/scope-manager": "8.46.4",
3426
  "@typescript-eslint/types": "8.46.4",
 
3702
  "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
3703
  "dev": true,
3704
  "license": "MIT",
 
3705
  "bin": {
3706
  "acorn": "bin/acorn"
3707
  },
 
3969
  }
3970
  },
3971
  "node_modules/body-parser": {
3972
+ "version": "2.2.1",
3973
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
3974
+ "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
3975
  "dev": true,
3976
  "license": "MIT",
3977
  "dependencies": {
3978
  "bytes": "^3.1.2",
3979
  "content-type": "^1.0.5",
3980
+ "debug": "^4.4.3",
3981
  "http-errors": "^2.0.0",
3982
+ "iconv-lite": "^0.7.0",
3983
  "on-finished": "^2.4.1",
3984
  "qs": "^6.14.0",
3985
+ "raw-body": "^3.0.1",
3986
+ "type-is": "^2.0.1"
3987
  },
3988
  "engines": {
3989
  "node": ">=18"
3990
+ },
3991
+ "funding": {
3992
+ "type": "opencollective",
3993
+ "url": "https://opencollective.com/express"
3994
  }
3995
  },
3996
  "node_modules/brace-expansion": {
 
4036
  }
4037
  ],
4038
  "license": "MIT",
 
4039
  "dependencies": {
4040
  "baseline-browser-mapping": "^2.8.25",
4041
  "caniuse-lite": "^1.0.30001754",
 
4735
  "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
4736
  "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
4737
  "license": "ISC",
 
4738
  "engines": {
4739
  "node": ">=12"
4740
  }
 
5152
  "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
5153
  "dev": true,
5154
  "license": "MIT",
 
5155
  "dependencies": {
5156
  "@eslint-community/eslint-utils": "^4.8.0",
5157
  "@eslint-community/regexpp": "^4.12.1",
 
5420
  "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
5421
  "dev": true,
5422
  "license": "MIT",
 
5423
  "dependencies": {
5424
  "accepts": "^2.0.0",
5425
  "body-parser": "^2.2.0",
 
6208
  }
6209
  },
6210
  "node_modules/iconv-lite": {
6211
+ "version": "0.7.0",
6212
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
6213
+ "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
6214
  "dev": true,
6215
  "license": "MIT",
6216
  "dependencies": {
 
6218
  },
6219
  "engines": {
6220
  "node": ">=0.10.0"
6221
+ },
6222
+ "funding": {
6223
+ "type": "opencollective",
6224
+ "url": "https://opencollective.com/express"
6225
  }
6226
  },
6227
  "node_modules/ignore": {
 
6545
  "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
6546
  "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
6547
  "license": "MIT",
 
6548
  "bin": {
6549
  "jiti": "bin/jiti.js"
6550
  }
 
8414
  }
8415
  ],
8416
  "license": "MIT",
 
8417
  "dependencies": {
8418
  "nanoid": "^3.3.11",
8419
  "picocolors": "^1.1.1",
 
8724
  "node": ">= 0.10"
8725
  }
8726
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8727
  "node_modules/react": {
8728
  "version": "19.2.0",
8729
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
8730
  "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
8731
  "license": "MIT",
 
8732
  "engines": {
8733
  "node": ">=0.10.0"
8734
  }
 
8738
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
8739
  "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
8740
  "license": "MIT",
 
8741
  "dependencies": {
8742
  "scheduler": "^0.27.0"
8743
  },
 
9828
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
9829
  "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
9830
  "license": "MIT",
 
9831
  "dependencies": {
9832
  "@alloc/quick-lru": "^5.2.0",
9833
  "arg": "^5.0.2",
 
9965
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
9966
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
9967
  "license": "MIT",
 
9968
  "engines": {
9969
  "node": ">=12"
9970
  },
 
10144
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
10145
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
10146
  "license": "Apache-2.0",
 
10147
  "bin": {
10148
  "tsc": "bin/tsc",
10149
  "tsserver": "bin/tsserver"
 
10546
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
10547
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
10548
  "license": "MIT",
 
10549
  "engines": {
10550
  "node": ">=12"
10551
  },
 
10736
  "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
10737
  "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
10738
  "license": "ISC",
 
10739
  "bin": {
10740
  "yaml": "bin.mjs"
10741
  },
 
10862
  "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
10863
  "dev": true,
10864
  "license": "MIT",
 
10865
  "funding": {
10866
  "url": "https://github.com/sponsors/colinhacks"
10867
  }
frontend/src/components/NoteViewer.tsx CHANGED
@@ -5,7 +5,7 @@
5
  import { useMemo } from 'react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
- import { Edit, Trash2, Calendar, Tag as TagIcon, ArrowLeft } from 'lucide-react';
9
  import { Badge } from '@/components/ui/badge';
10
  import { Button } from '@/components/ui/button';
11
  import { ScrollArea } from '@/components/ui/scroll-area';
@@ -20,6 +20,10 @@ interface NoteViewerProps {
20
  onEdit?: () => void;
21
  onDelete?: () => void;
22
  onWikilinkClick: (linkText: string) => void;
 
 
 
 
23
  }
24
 
25
  export function NoteViewer({
@@ -28,6 +32,10 @@ export function NoteViewer({
28
  onEdit,
29
  onDelete,
30
  onWikilinkClick,
 
 
 
 
31
  }: NoteViewerProps) {
32
  // Create custom markdown components with wikilink handler
33
  const markdownComponents = useMemo(
@@ -70,6 +78,37 @@ export function NoteViewer({
70
  <p className="text-sm text-muted-foreground mt-1 animate-fade-in" style={{ animationDelay: '0.2s' }}>{note.note_path}</p>
71
  </div>
72
  <div className="flex gap-2 animate-fade-in" style={{ animationDelay: '0.15s' }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  {onEdit && (
74
  <Button variant="outline" size="sm" onClick={onEdit}>
75
  <Edit className="h-4 w-4 mr-2" />
 
5
  import { useMemo } from 'react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
+ import { Edit, Trash2, Calendar, Tag as TagIcon, ArrowLeft, Volume2, Pause, Play, Square } from 'lucide-react';
9
  import { Badge } from '@/components/ui/badge';
10
  import { Button } from '@/components/ui/button';
11
  import { ScrollArea } from '@/components/ui/scroll-area';
 
20
  onEdit?: () => void;
21
  onDelete?: () => void;
22
  onWikilinkClick: (linkText: string) => void;
23
+ ttsStatus?: 'idle' | 'loading' | 'playing' | 'paused' | 'error';
24
+ onTtsToggle?: () => void;
25
+ onTtsStop?: () => void;
26
+ ttsDisabledReason?: string;
27
  }
28
 
29
  export function NoteViewer({
 
32
  onEdit,
33
  onDelete,
34
  onWikilinkClick,
35
+ ttsStatus = 'idle',
36
+ onTtsToggle,
37
+ onTtsStop,
38
+ ttsDisabledReason,
39
  }: NoteViewerProps) {
40
  // Create custom markdown components with wikilink handler
41
  const markdownComponents = useMemo(
 
78
  <p className="text-sm text-muted-foreground mt-1 animate-fade-in" style={{ animationDelay: '0.2s' }}>{note.note_path}</p>
79
  </div>
80
  <div className="flex gap-2 animate-fade-in" style={{ animationDelay: '0.15s' }}>
81
+ {onTtsToggle && (
82
+ <div className="flex gap-2">
83
+ <Button
84
+ variant="outline"
85
+ size="sm"
86
+ onClick={onTtsToggle}
87
+ disabled={Boolean(ttsDisabledReason) || ttsStatus === 'loading'}
88
+ title={ttsDisabledReason || undefined}
89
+ >
90
+ {ttsStatus === 'playing' ? (
91
+ <Pause className="h-4 w-4 mr-2" />
92
+ ) : ttsStatus === 'paused' ? (
93
+ <Play className="h-4 w-4 mr-2" />
94
+ ) : (
95
+ <Volume2 className="h-4 w-4 mr-2" />
96
+ )}
97
+ {ttsStatus === 'playing'
98
+ ? 'Pause TTS'
99
+ : ttsStatus === 'paused'
100
+ ? 'Resume TTS'
101
+ : ttsStatus === 'loading'
102
+ ? 'Loading...'
103
+ : 'TTS Mode'}
104
+ </Button>
105
+ {(ttsStatus === 'playing' || ttsStatus === 'paused' || ttsStatus === 'error') && onTtsStop && (
106
+ <Button variant="ghost" size="sm" onClick={onTtsStop} title="Stop TTS">
107
+ <Square className="h-4 w-4" />
108
+ </Button>
109
+ )}
110
+ </div>
111
+ )}
112
  {onEdit && (
113
  <Button variant="outline" size="sm" onClick={onEdit}>
114
  <Edit className="h-4 w-4 mr-2" />
frontend/src/hooks/useAudioPlayer.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ type PlayerStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
4
+
5
+ interface AudioPlayer {
6
+ status: PlayerStatus;
7
+ error: string | null;
8
+ play: (src: string) => void;
9
+ pause: () => void;
10
+ resume: () => void;
11
+ stop: () => void;
12
+ }
13
+
14
+ /**
15
+ * Lightweight audio controller for blob URLs returned by the TTS service.
16
+ */
17
+ export function useAudioPlayer(): AudioPlayer {
18
+ const audioRef = useRef<HTMLAudioElement | null>(null);
19
+ const [status, setStatus] = useState<PlayerStatus>('idle');
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ const cleanup = () => {
23
+ const audio = audioRef.current;
24
+ if (audio) {
25
+ audio.pause();
26
+ audio.src = '';
27
+ audioRef.current = null;
28
+ }
29
+ };
30
+
31
+ const stop = () => {
32
+ cleanup();
33
+ setStatus('idle');
34
+ };
35
+
36
+ const play = (src: string) => {
37
+ setError(null);
38
+ cleanup();
39
+ setStatus('loading');
40
+ const audio = new Audio(src);
41
+ audioRef.current = audio;
42
+
43
+ audio.oncanplay = () => {
44
+ audio.play().catch((err) => {
45
+ setError(err?.message || 'Failed to play audio.');
46
+ setStatus('error');
47
+ });
48
+ };
49
+ audio.onplay = () => setStatus('playing');
50
+ audio.onpause = () => setStatus((prev) => (prev === 'loading' ? 'loading' : 'paused'));
51
+ audio.onended = () => setStatus('idle');
52
+ audio.onerror = () => {
53
+ setError('Audio playback error.');
54
+ setStatus('error');
55
+ };
56
+ };
57
+
58
+ const pause = () => {
59
+ const audio = audioRef.current;
60
+ if (audio && !audio.paused) {
61
+ audio.pause();
62
+ }
63
+ };
64
+
65
+ const resume = () => {
66
+ const audio = audioRef.current;
67
+ if (audio && audio.paused) {
68
+ audio.play().catch((err) => {
69
+ setError(err?.message || 'Failed to resume audio.');
70
+ setStatus('error');
71
+ });
72
+ }
73
+ };
74
+
75
+ useEffect(() => {
76
+ return () => {
77
+ cleanup();
78
+ };
79
+ }, []);
80
+
81
+ return { status, error, play, pause, resume, stop };
82
+ }
frontend/src/lib/markdownToText.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Convert markdown content into a plaintext string suitable for TTS.
3
+ * Strips code blocks, images, and excess whitespace.
4
+ */
5
+ export function markdownToPlainText(markdown: string): string {
6
+ if (!markdown) return '';
7
+
8
+ let text = markdown;
9
+
10
+ // Remove fenced code blocks
11
+ text = text.replace(/```[\\s\\S]*?```/g, '');
12
+ // Remove inline code
13
+ text = text.replace(/`([^`]*)`/g, '$1');
14
+ // Remove images: ![alt](url)
15
+ text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
16
+ // Replace markdown links with link text
17
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
18
+ // Replace headings with emphasized text
19
+ text = text.replace(/^(#{1,6})\s*(.*)$/gm, '$2');
20
+ // Replace list markers with dash
21
+ text = text.replace(/^\s*[-*+]\s+/gm, '- ');
22
+ // Normalize whitespace
23
+ text = text.replace(/\\s+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n');
24
+
25
+ return text.trim();
26
+ }
frontend/src/pages/MainApp.tsx CHANGED
@@ -2,7 +2,7 @@
2
  * T080, T083-T084: Main application layout with two-pane design
3
  * Loads directory tree on mount and note + backlinks when path changes
4
  */
5
- import { useState, useEffect } from 'react';
6
  import { useNavigate } from 'react-router-dom';
7
  import { Plus, Settings as SettingsIcon, FolderPlus, MessageCircle } from 'lucide-react';
8
  import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
@@ -43,6 +43,9 @@ import type { Note, NoteSummary } from '@/types/note';
43
  import { normalizeSlug } from '@/lib/wikilink';
44
  import { Network } from 'lucide-react';
45
  import { AUTH_TOKEN_CHANGED_EVENT, isDemoSession, login } from '@/services/auth';
 
 
 
46
 
47
  export function MainApp() {
48
  const navigate = useNavigate();
@@ -65,6 +68,29 @@ export function MainApp() {
65
  const [isCreatingFolder, setIsCreatingFolder] = useState(false);
66
  const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession());
67
  const [isChatOpen, setIsChatOpen] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  useEffect(() => {
70
  const handleAuthChange = () => {
@@ -80,6 +106,12 @@ export function MainApp() {
80
  };
81
  }, []);
82
 
 
 
 
 
 
 
83
  // T083: Load directory tree on mount
84
  // T119: Load index health
85
  useEffect(() => {
@@ -115,6 +147,17 @@ export function MainApp() {
115
  loadData();
116
  }, []);
117
 
 
 
 
 
 
 
 
 
 
 
 
118
  // T084: Load note and backlinks when path changes
119
  useEffect(() => {
120
  if (!selectedPath) {
@@ -179,6 +222,57 @@ export function MainApp() {
179
  }
180
  };
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  const handleSelectNote = (path: string) => {
183
  setSelectedPath(path);
184
  setError(null);
@@ -391,6 +485,9 @@ export function MainApp() {
391
  }
392
  };
393
 
 
 
 
394
  return (
395
  <div className="h-screen flex flex-col">
396
  {/* Demo warning banner */}
@@ -608,6 +705,10 @@ export function MainApp() {
608
  backlinks={backlinks}
609
  onEdit={isDemoMode ? undefined : handleEdit}
610
  onWikilinkClick={handleWikilinkClick}
 
 
 
 
611
  />
612
  )
613
  ) : (
 
2
  * T080, T083-T084: Main application layout with two-pane design
3
  * Loads directory tree on mount and note + backlinks when path changes
4
  */
5
+ import { useState, useEffect, useRef } from 'react';
6
  import { useNavigate } from 'react-router-dom';
7
  import { Plus, Settings as SettingsIcon, FolderPlus, MessageCircle } from 'lucide-react';
8
  import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
 
43
  import { normalizeSlug } from '@/lib/wikilink';
44
  import { Network } from 'lucide-react';
45
  import { AUTH_TOKEN_CHANGED_EVENT, isDemoSession, login } from '@/services/auth';
46
+ import { synthesizeTts } from '@/services/tts';
47
+ import { markdownToPlainText } from '@/lib/markdownToText';
48
+ import { useAudioPlayer } from '@/hooks/useAudioPlayer';
49
 
50
  export function MainApp() {
51
  const navigate = useNavigate();
 
68
  const [isCreatingFolder, setIsCreatingFolder] = useState(false);
69
  const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession());
70
  const [isChatOpen, setIsChatOpen] = useState(false);
71
+ const [isSynthesizingTts, setIsSynthesizingTts] = useState(false);
72
+ const ttsUrlRef = useRef<string | null>(null);
73
+ const ttsAbortRef = useRef<AbortController | null>(null);
74
+ const {
75
+ status: ttsPlayerStatus,
76
+ error: ttsPlayerError,
77
+ play: playAudio,
78
+ pause: pauseAudio,
79
+ resume: resumeAudio,
80
+ stop: stopAudio,
81
+ } = useAudioPlayer();
82
+ const stopTts = () => {
83
+ if (ttsAbortRef.current) {
84
+ ttsAbortRef.current.abort();
85
+ ttsAbortRef.current = null;
86
+ }
87
+ stopAudio();
88
+ if (ttsUrlRef.current) {
89
+ URL.revokeObjectURL(ttsUrlRef.current);
90
+ ttsUrlRef.current = null;
91
+ }
92
+ setIsSynthesizingTts(false);
93
+ };
94
 
95
  useEffect(() => {
96
  const handleAuthChange = () => {
 
106
  };
107
  }, []);
108
 
109
+ useEffect(() => {
110
+ if (ttsPlayerError) {
111
+ toast.error(ttsPlayerError);
112
+ }
113
+ }, [ttsPlayerError, toast]);
114
+
115
  // T083: Load directory tree on mount
116
  // T119: Load index health
117
  useEffect(() => {
 
147
  loadData();
148
  }, []);
149
 
150
+ useEffect(() => {
151
+ // Stop TTS when switching notes
152
+ stopTts();
153
+ }, [selectedPath]);
154
+
155
+ useEffect(() => {
156
+ return () => {
157
+ stopTts();
158
+ };
159
+ }, []);
160
+
161
  // T084: Load note and backlinks when path changes
162
  useEffect(() => {
163
  if (!selectedPath) {
 
222
  }
223
  };
224
 
225
+ const handleTtsToggle = async () => {
226
+ if (!currentNote || isSynthesizingTts) {
227
+ return;
228
+ }
229
+
230
+ if (ttsPlayerStatus === 'playing') {
231
+ pauseAudio();
232
+ return;
233
+ }
234
+
235
+ if (ttsPlayerStatus === 'paused') {
236
+ resumeAudio();
237
+ return;
238
+ }
239
+
240
+ const plainText = markdownToPlainText(currentNote.body);
241
+ if (!plainText) {
242
+ toast.error('No readable content in this note.');
243
+ return;
244
+ }
245
+
246
+ if (ttsAbortRef.current) {
247
+ ttsAbortRef.current.abort();
248
+ }
249
+ const controller = new AbortController();
250
+ ttsAbortRef.current = controller;
251
+ setIsSynthesizingTts(true);
252
+ try {
253
+ const blob = await synthesizeTts(plainText, { signal: controller.signal });
254
+ if (ttsUrlRef.current) {
255
+ URL.revokeObjectURL(ttsUrlRef.current);
256
+ }
257
+ const url = URL.createObjectURL(blob);
258
+ ttsUrlRef.current = url;
259
+ playAudio(url);
260
+ } catch (err) {
261
+ if (err instanceof DOMException && err.name === 'AbortError') {
262
+ return;
263
+ }
264
+ const message = err instanceof Error ? err.message : 'Failed to generate speech.';
265
+ toast.error(message);
266
+ } finally {
267
+ ttsAbortRef.current = null;
268
+ setIsSynthesizingTts(false);
269
+ }
270
+ };
271
+
272
+ const handleTtsStop = () => {
273
+ stopTts();
274
+ };
275
+
276
  const handleSelectNote = (path: string) => {
277
  setSelectedPath(path);
278
  setError(null);
 
485
  }
486
  };
487
 
488
+ const ttsStatus = isSynthesizingTts ? 'loading' : ttsPlayerStatus;
489
+ const ttsDisabledReason = undefined;
490
+
491
  return (
492
  <div className="h-screen flex flex-col">
493
  {/* Demo warning banner */}
 
705
  backlinks={backlinks}
706
  onEdit={isDemoMode ? undefined : handleEdit}
707
  onWikilinkClick={handleWikilinkClick}
708
+ ttsStatus={ttsStatus}
709
+ onTtsToggle={handleTtsToggle}
710
+ onTtsStop={handleTtsStop}
711
+ ttsDisabledReason={ttsDisabledReason}
712
  />
713
  )
714
  ) : (
frontend/src/services/tts.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ElevenLabs TTS client hitting the backend proxy.
3
+ */
4
+
5
+ export interface TtsOptions {
6
+ voiceId?: string;
7
+ model?: string;
8
+ signal?: AbortSignal;
9
+ }
10
+
11
+ export async function synthesizeTts(text: string, options: TtsOptions = {}): Promise<Blob> {
12
+ const token = localStorage.getItem('auth_token');
13
+ const response = await fetch('/api/tts', {
14
+ method: 'POST',
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
18
+ },
19
+ signal: options.signal,
20
+ body: JSON.stringify({
21
+ text,
22
+ voice_id: options.voiceId,
23
+ model: options.model,
24
+ }),
25
+ });
26
+
27
+ if (!response.ok) {
28
+ let message = `TTS failed (HTTP ${response.status})`;
29
+ try {
30
+ const data = await response.json();
31
+ message = data?.message || data?.detail?.message || message;
32
+ } catch {
33
+ // ignore parse errors
34
+ }
35
+ throw new Error(message);
36
+ }
37
+
38
+ return response.blob();
39
+ }