openvoiceui 1.0.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.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,269 @@
1
+ """
2
+ skill-runner/server.py — Shared JamBot skill execution service
3
+
4
+ Runs on jambot-shared Docker network at http://skill-runner:8900
5
+ NOT exposed to the host or the internet — internal only.
6
+
7
+ Endpoints:
8
+ GET /health — liveness check
9
+ POST /extract — document text extraction (PDF, DOCX, XLSX, PPTX)
10
+ POST /analyze-csv — pandas CSV summary + stats
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import re
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ from flask import Flask, jsonify, request
20
+
21
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
22
+ logger = logging.getLogger(__name__)
23
+
24
+ app = Flask(__name__)
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Constants
28
+ # ---------------------------------------------------------------------------
29
+
30
+ MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB — matches openvoiceui upload limit
31
+ MAX_PREVIEW_CHARS = 6000
32
+
33
+ ALLOWED_EXTRACT_EXTENSIONS = {
34
+ '.pdf', '.docx', '.xlsx', '.pptx',
35
+ }
36
+
37
+ ALLOWED_CSV_EXTENSIONS = {'.csv', '.tsv'}
38
+
39
+ # Control characters: strip everything except \t \n \r
40
+ _CTRL_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Sanitization
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def sanitize(text: str) -> str:
48
+ """Strip control chars, collapse blank lines, cap at MAX_PREVIEW_CHARS."""
49
+ text = _CTRL_RE.sub('', text)
50
+ text = re.sub(r'\n{4,}', '\n\n\n', text)
51
+ return text[:MAX_PREVIEW_CHARS].strip()
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Extractors
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def extract_pdf(path: Path) -> tuple[str, dict]:
59
+ from pypdf import PdfReader
60
+ reader = PdfReader(str(path))
61
+ pages = []
62
+ for i, page in enumerate(reader.pages):
63
+ try:
64
+ pages.append(page.extract_text() or '')
65
+ except Exception:
66
+ pages.append(f'[Page {i + 1}: extraction failed]')
67
+ text = sanitize('\n\n'.join(pages))
68
+ return text, {'pages': len(reader.pages)}
69
+
70
+
71
+ def extract_docx(path: Path) -> tuple[str, dict]:
72
+ from docx import Document
73
+ doc = Document(str(path))
74
+ parts = []
75
+ for para in doc.paragraphs:
76
+ t = para.text.strip()
77
+ if t:
78
+ parts.append(t)
79
+ for table in doc.tables:
80
+ for row in table.rows:
81
+ cells = [c.text.strip() for c in row.cells if c.text.strip()]
82
+ if cells:
83
+ parts.append(' | '.join(cells))
84
+ text = sanitize('\n'.join(parts))
85
+ return text, {'paragraphs': len(doc.paragraphs), 'tables': len(doc.tables)}
86
+
87
+
88
+ def extract_xlsx(path: Path) -> tuple[str, dict]:
89
+ import openpyxl
90
+ wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
91
+ parts = []
92
+ sheet_count = 0
93
+ try:
94
+ for sheet in wb.worksheets:
95
+ sheet_count += 1
96
+ parts.append(f'[Sheet: {sheet.title}]')
97
+ for row in sheet.iter_rows(values_only=True):
98
+ cells = [str(c) for c in row if c is not None and str(c).strip()]
99
+ if cells:
100
+ parts.append('\t'.join(cells))
101
+ finally:
102
+ wb.close()
103
+ text = sanitize('\n'.join(parts))
104
+ return text, {'sheets': sheet_count}
105
+
106
+
107
+ def extract_pptx(path: Path) -> tuple[str, dict]:
108
+ from pptx import Presentation
109
+ prs = Presentation(str(path))
110
+ parts = []
111
+ for i, slide in enumerate(prs.slides, 1):
112
+ slide_texts = []
113
+ for shape in slide.shapes:
114
+ if shape.has_text_frame:
115
+ for para in shape.text_frame.paragraphs:
116
+ t = para.text.strip()
117
+ if t:
118
+ slide_texts.append(t)
119
+ if slide_texts:
120
+ parts.append(f'[Slide {i}]')
121
+ parts.extend(slide_texts)
122
+ text = sanitize('\n'.join(parts))
123
+ return text, {'slides': len(prs.slides)}
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Routes
128
+ # ---------------------------------------------------------------------------
129
+
130
+ @app.route('/health')
131
+ def health():
132
+ return jsonify({'status': 'ok', 'service': 'skill-runner'})
133
+
134
+
135
+ @app.route('/extract', methods=['POST'])
136
+ def extract():
137
+ """
138
+ Extract text from a document file.
139
+
140
+ Accepts: multipart/form-data with:
141
+ file — the document bytes
142
+ filename — original filename (used to determine type; optional, falls back to file.filename)
143
+
144
+ Returns:
145
+ {text, type, chars, ...type-specific meta}
146
+ """
147
+ if 'file' not in request.files:
148
+ return jsonify({'error': 'No file provided'}), 400
149
+
150
+ f = request.files['file']
151
+ filename = request.form.get('filename') or f.filename or ''
152
+ ext = Path(filename).suffix.lower()
153
+
154
+ if ext not in ALLOWED_EXTRACT_EXTENSIONS:
155
+ return jsonify({'error': f'Unsupported extension for extraction: {ext}'}), 415
156
+
157
+ # Size check before writing to disk
158
+ f.stream.seek(0, 2)
159
+ size = f.stream.tell()
160
+ f.stream.seek(0)
161
+ if size > MAX_FILE_BYTES:
162
+ return jsonify({'error': 'File too large (25 MB max)'}), 413
163
+
164
+ # Write to a secure temp file
165
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
166
+ tmp_path = Path(tmp.name)
167
+ f.save(tmp_path)
168
+
169
+ try:
170
+ if ext == '.pdf':
171
+ text, meta = extract_pdf(tmp_path)
172
+ elif ext == '.docx':
173
+ text, meta = extract_docx(tmp_path)
174
+ elif ext == '.xlsx':
175
+ text, meta = extract_xlsx(tmp_path)
176
+ elif ext == '.pptx':
177
+ text, meta = extract_pptx(tmp_path)
178
+ else:
179
+ return jsonify({'error': f'No extractor for {ext}'}), 415
180
+
181
+ except Exception as exc:
182
+ logger.exception('Extraction failed for %s', filename)
183
+ return jsonify({'error': f'Extraction failed: {exc}'}), 500
184
+ finally:
185
+ try:
186
+ tmp_path.unlink()
187
+ except Exception:
188
+ pass
189
+
190
+ logger.info('Extracted %s → %d chars (%s)', filename, len(text), ext)
191
+ return jsonify({'text': text, 'type': ext.lstrip('.'), 'chars': len(text), **meta})
192
+
193
+
194
+ @app.route('/analyze-csv', methods=['POST'])
195
+ def analyze_csv():
196
+ """
197
+ Run pandas summary analysis on a CSV/TSV file.
198
+
199
+ Accepts: multipart/form-data with:
200
+ file — the CSV bytes
201
+ filename — original filename
202
+
203
+ Returns:
204
+ {summary, rows, columns, dtypes, missing}
205
+ """
206
+ if 'file' not in request.files:
207
+ return jsonify({'error': 'No file provided'}), 400
208
+
209
+ f = request.files['file']
210
+ filename = request.form.get('filename') or f.filename or ''
211
+ ext = Path(filename).suffix.lower()
212
+
213
+ if ext not in ALLOWED_CSV_EXTENSIONS:
214
+ return jsonify({'error': f'Unsupported extension: {ext}. Send .csv or .tsv'}), 415
215
+
216
+ f.stream.seek(0, 2)
217
+ size = f.stream.tell()
218
+ f.stream.seek(0)
219
+ if size > MAX_FILE_BYTES:
220
+ return jsonify({'error': 'File too large (25 MB max)'}), 413
221
+
222
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
223
+ tmp_path = Path(tmp.name)
224
+ f.save(tmp_path)
225
+
226
+ try:
227
+ import pandas as pd
228
+ sep = '\t' if ext == '.tsv' else ','
229
+ df = pd.read_csv(tmp_path, sep=sep)
230
+
231
+ rows, cols = df.shape
232
+ dtypes = {col: str(dtype) for col, dtype in df.dtypes.items()}
233
+ missing = {col: int(df[col].isna().sum()) for col in df.columns if df[col].isna().any()}
234
+
235
+ # Build a readable text summary
236
+ desc = df.describe(include='all').to_string()
237
+ summary = sanitize(
238
+ f'Rows: {rows}, Columns: {cols}\n\nColumn types:\n'
239
+ + '\n'.join(f' {c}: {t}' for c, t in dtypes.items())
240
+ + f'\n\nStats:\n{desc}'
241
+ )
242
+
243
+ except Exception as exc:
244
+ logger.exception('CSV analysis failed for %s', filename)
245
+ return jsonify({'error': f'Analysis failed: {exc}'}), 500
246
+ finally:
247
+ try:
248
+ tmp_path.unlink()
249
+ except Exception:
250
+ pass
251
+
252
+ logger.info('Analyzed CSV %s → %d rows × %d cols', filename, rows, cols)
253
+ return jsonify({
254
+ 'summary': summary,
255
+ 'rows': rows,
256
+ 'columns': cols,
257
+ 'dtypes': dtypes,
258
+ 'missing': missing,
259
+ })
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Main
264
+ # ---------------------------------------------------------------------------
265
+
266
+ if __name__ == '__main__':
267
+ port = int(os.environ.get('PORT', 8900))
268
+ logger.info('skill-runner starting on port %d', port)
269
+ app.run(host='0.0.0.0', port=port, threaded=True)
@@ -0,0 +1,22 @@
1
+ FROM python:3.12-slim
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends libsndfile1 \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ RUN pip install --no-cache-dir supertonic==1.1.2 fastapi uvicorn soundfile numpy
8
+
9
+ RUN useradd -r -m appuser
10
+
11
+ WORKDIR /app
12
+ COPY server.py .
13
+ RUN chown -R appuser:appuser /app
14
+
15
+ USER appuser
16
+
17
+ # Pre-download models as appuser so the cache path matches runtime
18
+ RUN python3 -c "from supertonic import TTS; tts = TTS(auto_download=True)"
19
+
20
+ EXPOSE 8765
21
+
22
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8765"]
@@ -0,0 +1,79 @@
1
+ """Supertonic TTS microservice — thin FastAPI wrapper around `supertonic`."""
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+ from io import BytesIO
6
+
7
+ import numpy as np
8
+ import soundfile as sf
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.responses import Response
11
+ from pydantic import BaseModel, Field
12
+ from supertonic import TTS
13
+
14
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
15
+ logger = logging.getLogger("supertonic-service")
16
+
17
+ VOICES = ["M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"]
18
+ LANGUAGES = ["en", "ko", "es", "pt", "fr"]
19
+
20
+ # Pre-loaded voice styles keyed by name
21
+ _styles: dict = {}
22
+ _tts: TTS | None = None
23
+
24
+
25
+ @asynccontextmanager
26
+ async def lifespan(_app: FastAPI):
27
+ """Load TTS engine and all voice styles once at startup."""
28
+ global _tts
29
+ logger.info("Loading Supertonic TTS engine …")
30
+ _tts = TTS(auto_download=True)
31
+ for voice in VOICES:
32
+ logger.info("Loading voice style %s …", voice)
33
+ _styles[voice] = _tts.get_voice_style(voice)
34
+ logger.info("All %d voices loaded — ready to serve.", len(_styles))
35
+ yield
36
+
37
+
38
+ app = FastAPI(title="Supertonic TTS", lifespan=lifespan)
39
+
40
+
41
+ class TTSRequest(BaseModel):
42
+ text: str
43
+ voice: str = "F3"
44
+ speed: float = Field(default=1.05, gt=0, le=2)
45
+ steps: int = Field(default=40, ge=1, le=100)
46
+ lang: str = "en"
47
+ silence_duration: float = Field(default=0.1, ge=0.0, le=2.0)
48
+
49
+
50
+ @app.get("/health")
51
+ async def health():
52
+ return {"status": "ok"}
53
+
54
+
55
+ @app.post("/tts")
56
+ async def tts(req: TTSRequest):
57
+ if req.voice not in _styles:
58
+ raise HTTPException(400, f"Unknown voice '{req.voice}'. Available: {VOICES}")
59
+ if req.lang not in LANGUAGES:
60
+ raise HTTPException(400, f"Unsupported lang '{req.lang}'. Available: {LANGUAGES}")
61
+ if not req.text.strip():
62
+ raise HTTPException(400, "Text cannot be empty")
63
+
64
+ try:
65
+ wav, duration = _tts(
66
+ text=req.text,
67
+ lang=req.lang,
68
+ voice_style=_styles[req.voice],
69
+ speed=req.speed,
70
+ total_steps=req.steps,
71
+ silence_duration=req.silence_duration,
72
+ )
73
+ audio = wav[0, : int(_tts.sample_rate * duration[0].item())]
74
+ buf = BytesIO()
75
+ sf.write(buf, audio, _tts.sample_rate, format="WAV")
76
+ return Response(content=buf.getvalue(), media_type="audio/wav")
77
+ except Exception as exc:
78
+ logger.exception("TTS generation failed")
79
+ raise HTTPException(500, str(exc))
@@ -0,0 +1,11 @@
1
+ # Pinokio local install override
2
+ # - Swaps openclaw-data volume for local dir (pre-configured openclaw.json + auth-profiles)
3
+ # - Injects .env into both containers so API keys are available
4
+ # - dangerouslyDisableDeviceAuth is set in openclaw.json so no device pairing needed
5
+ services:
6
+ openclaw:
7
+ env_file:
8
+ - .env
9
+ volumes:
10
+ - ./openclaw-data:/root/.openclaw
11
+ - canvas-pages:/root/.openclaw/workspace/canvas-pages
@@ -0,0 +1,59 @@
1
+ services:
2
+ openclaw:
3
+ build:
4
+ context: deploy/openclaw
5
+ args:
6
+ OPENCLAW_VERSION: ${OPENCLAW_VERSION:-2026.3.13}
7
+ CODING_CLI: ${CODING_CLI:-none}
8
+ volumes:
9
+ - openclaw-data:/root/.openclaw
10
+ - canvas-pages:/root/.openclaw/workspace/canvas-pages
11
+ ports:
12
+ - "18791:18791"
13
+ - "${PORT:-5001}:${PORT:-5001}"
14
+ restart: unless-stopped
15
+ healthcheck:
16
+ test: ["CMD", "node", "-e", "const h=require('http');h.get('http://localhost:18791',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))"]
17
+ interval: 30s
18
+ timeout: 10s
19
+ retries: 3
20
+ start_period: 30s
21
+
22
+ supertonic:
23
+ build: deploy/supertonic
24
+ restart: unless-stopped
25
+ healthcheck:
26
+ test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8765/health')"]
27
+ interval: 30s
28
+ timeout: 10s
29
+ retries: 5
30
+ start_period: 120s
31
+
32
+ openvoiceui:
33
+ build: .
34
+ network_mode: "service:openclaw"
35
+ env_file:
36
+ - .env
37
+ environment:
38
+ - CLAWDBOT_GATEWAY_URL=ws://127.0.0.1:18791
39
+ - SUPERTONIC_API_URL=http://supertonic:8765
40
+ volumes:
41
+ - openvoiceui-runtime:/app/runtime
42
+ - canvas-pages:/app/runtime/canvas-pages
43
+ depends_on:
44
+ openclaw:
45
+ condition: service_healthy
46
+ supertonic:
47
+ condition: service_healthy
48
+ restart: unless-stopped
49
+ healthcheck:
50
+ test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/health/ready', timeout=5)"]
51
+ interval: 30s
52
+ timeout: 10s
53
+ retries: 3
54
+ start_period: 20s
55
+
56
+ volumes:
57
+ openclaw-data:
58
+ openvoiceui-runtime:
59
+ canvas-pages:
package/greetings.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "version": 1,
3
+ "updated_at": "",
4
+ "updated_by": "system",
5
+ "notes": "Default greeting library. Edit or extend via /api/greetings/add. Categories: generic (all users) and contextual (face-recognized users).",
6
+ "next_greeting": null,
7
+ "last_context": null,
8
+ "greetings": {
9
+ "generic": {
10
+ "standard": [
11
+ "Hello! How can I help you today?",
12
+ "Hey there! What can I do for you?",
13
+ "Hi! What's on your mind?",
14
+ "Good to see you. What do you need?",
15
+ "Hello! Ready when you are.",
16
+ "Hey! What can I help you with?",
17
+ "Hi there! Ask me anything.",
18
+ "What can I do for you today?",
19
+ "Hey, what's up? How can I help?",
20
+ "Hello! What would you like to talk about?"
21
+ ]
22
+ },
23
+ "contextual": []
24
+ }
25
+ }
package/index.html ADDED
@@ -0,0 +1,65 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <title>OpenVoiceUI</title>
7
+
8
+ <!-- Favicon -->
9
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
10
+ <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
11
+
12
+ <!-- PWA -->
13
+ <link rel="manifest" href="/manifest.json">
14
+ <meta name="theme-color" content="#060610">
15
+ <meta name="mobile-web-app-capable" content="yes">
16
+ <!-- iOS PWA (Safari "Add to Home Screen") -->
17
+ <meta name="apple-mobile-web-app-capable" content="yes">
18
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
19
+ <meta name="apple-mobile-web-app-title" content="OpenVoiceUI">
20
+ <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
21
+
22
+ <!-- iOS Safari Audio Fix — must run before any AudioContext usage -->
23
+ <script>(function(){var A=window.AudioContext||window.webkitAudioContext;if(!A)return;window.audioContext=new A();var u=false;function f(){if(u)return;var b=window.audioContext.createBuffer(1,1,22050),s=window.audioContext.createBufferSource();s.buffer=b;s.connect(window.audioContext.destination);(s.start||s.noteOn).call(s,0);window.audioContext.resume().then(function(){u=true;}).catch(function(){});}['touchstart','touchend','click','keydown'].forEach(function(e){document.addEventListener(e,f,{passive:true});});window.isAudioUnlocked=function(){return u;};}());
24
+ // Register PWA service worker
25
+ if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js',{scope:'/'}).then(function(r){console.log('[SW] registered:',r.scope);}).catch(function(e){console.warn('[SW] registration failed:',e);});}
26
+ </script>
27
+
28
+ <!-- Styles -->
29
+ <link rel="stylesheet" href="src/styles/theme-dark.css?v=3">
30
+ <link rel="stylesheet" href="src/styles/face.css?v=10">
31
+ <link rel="stylesheet" href="src/styles/base.css?v=11">
32
+ <link rel="stylesheet" href="src/ui/settings/SettingsPanel.css?v=3">
33
+
34
+ <!-- Mobile overrides — inlined to bypass any CSS file caching -->
35
+ <style>
36
+ @media (max-width: 500px) {
37
+ .eye-cap-top { top: -13px; }
38
+ }
39
+ </style>
40
+
41
+ <!-- UI modules (global scripts — must load before app module) -->
42
+ <script src="src/ui/themes/ThemeManager.js?v=2"></script>
43
+ <script src="src/ui/face/FaceRenderer.js?v=2"></script>
44
+ <script src="src/face/HaloSmokeFace.js?v=2"></script>
45
+ <script src="src/ui/face/FacePicker.js?v=2"></script>
46
+ <script src="src/ui/settings/SettingsPanel.js?v=2"></script>
47
+
48
+ <!-- Clerk Authentication (optional — set CLERK_PUBLISHABLE_KEY in .env) -->
49
+ <script>
50
+ // Only load Clerk if a key is injected by the server
51
+ if (window.AGENT_CONFIG?.clerkPublishableKey) {
52
+ const s = document.createElement('script');
53
+ s.async = true;
54
+ s.crossOrigin = 'anonymous';
55
+ s.dataset.clerkPublishableKey = window.AGENT_CONFIG.clerkPublishableKey;
56
+ s.src = 'https://cdn.jsdelivr.net/npm/@clerk/clerk-js@5/dist/clerk.browser.js';
57
+ document.head.appendChild(s);
58
+ }
59
+ </script>
60
+ </head>
61
+ <body>
62
+ <!-- App entry point: injects DOM shell + initializes all modules -->
63
+ <script type="module" src="src/app.js?v=19"></script>
64
+ </body>
65
+ </html>
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // inject-device-identity.js — Sync device identity between OpenVoiceUI and OpenClaw.
3
+ //
4
+ // Called by start.js AFTER containers are up but BEFORE the user opens the browser.
5
+ //
6
+ // Handles two scenarios:
7
+ // 1. Fresh install (no identity in volume): inject pre-paired identity
8
+ // 2. Reinstall (old identity in volume): read it, update paired.json to match
9
+ //
10
+ // Windows compatibility:
11
+ // - MSYS_NO_PATHCONV=1 prevents Git Bash from mangling /container/paths
12
+ // - docker cp avoids single-quote issues with cmd.exe
13
+ // - No sh -c with single quotes (Windows doesn't support them)
14
+
15
+ const { execSync } = require("child_process");
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const crypto = require("crypto");
19
+
20
+ const COMPOSE = "docker compose -f docker-compose.yml -f docker-compose.pinokio.yml";
21
+ const IDENTITY_FILE = "openclaw-data/pre-paired-device.json";
22
+ const PAIRED_FILE = "openclaw-data/devices/paired.json";
23
+ const CONTAINER_PATH = "/app/runtime/uploads/.device-identity.json";
24
+
25
+ // MSYS_NO_PATHCONV=1 prevents Git Bash on Windows from converting
26
+ // /app/runtime/... to E:/pinokio/bin/.../app/runtime/...
27
+ const EXEC_ENV = Object.assign({}, process.env, { MSYS_NO_PATHCONV: "1" });
28
+
29
+ function exec(cmd, opts) {
30
+ return execSync(cmd, Object.assign(
31
+ { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"], env: EXEC_ENV },
32
+ opts || {}
33
+ )).trim();
34
+ }
35
+
36
+ function writePairedJson(deviceId, publicKeyPem) {
37
+ // Derive base64url public key from PEM
38
+ var pubPemLines = publicKeyPem.split("\n").filter(function(l) {
39
+ return !l.startsWith("---") && l.trim();
40
+ });
41
+ var derBuf = Buffer.from(pubPemLines.join(""), "base64");
42
+ // Ed25519 SPKI DER = 12 byte header + 32 byte raw key
43
+ var rawPub = derBuf.slice(-32);
44
+ var pubB64url = rawPub.toString("base64url");
45
+
46
+ var nowMs = Date.now();
47
+ var pairingToken = crypto.randomBytes(32).toString("hex");
48
+ var paired = {};
49
+ paired[deviceId] = {
50
+ deviceId: deviceId,
51
+ publicKey: pubB64url,
52
+ displayName: "pinokio-openvoiceui",
53
+ platform: "linux",
54
+ clientId: "cli",
55
+ clientMode: "cli",
56
+ role: "operator",
57
+ roles: ["operator"],
58
+ scopes: ["operator.read", "operator.write"],
59
+ approvedScopes: ["operator.read", "operator.write"],
60
+ tokens: {
61
+ operator: {
62
+ token: pairingToken,
63
+ role: "operator",
64
+ scopes: ["operator.read", "operator.write"],
65
+ createdAtMs: nowMs,
66
+ },
67
+ },
68
+ createdAtMs: nowMs,
69
+ approvedAtMs: nowMs,
70
+ };
71
+
72
+ fs.mkdirSync("openclaw-data/devices", { recursive: true });
73
+ fs.writeFileSync(PAIRED_FILE, JSON.stringify(paired, null, 2) + "\n");
74
+ fs.writeFileSync("openclaw-data/devices/pending.json", "{}\n");
75
+ }
76
+
77
+ // Step 1: Check if the container already has a device identity (from a previous run)
78
+ var containerIdentity = null;
79
+ try {
80
+ var out = exec(COMPOSE + " exec -T openvoiceui cat " + CONTAINER_PATH);
81
+ if (out && out.startsWith("{")) {
82
+ containerIdentity = JSON.parse(out);
83
+ if (!containerIdentity.deviceId) containerIdentity = null;
84
+ }
85
+ } catch (e) {
86
+ // File doesn't exist — fresh volume
87
+ }
88
+
89
+ if (containerIdentity) {
90
+ // Scenario 2: Container already has an identity (Docker volume persisted it).
91
+ // Update paired.json to match whatever the container has.
92
+ console.log(" Found existing device identity: " + containerIdentity.deviceId.slice(0, 16) + "...");
93
+
94
+ try {
95
+ writePairedJson(containerIdentity.deviceId, containerIdentity.publicKeyPem);
96
+ console.log(" Updated paired.json to match container identity");
97
+
98
+ // Save locally so they stay in sync
99
+ fs.writeFileSync(IDENTITY_FILE, JSON.stringify(containerIdentity, null, 2) + "\n");
100
+
101
+ // No restart needed — openclaw-data/ is a bind mount so paired.json
102
+ // changes are immediately visible inside the container. OpenClaw reads
103
+ // paired.json on each WS connection attempt (getPairedDevice()), not
104
+ // just at startup. DO NOT restart openclaw here — openvoiceui uses
105
+ // network_mode: "service:openclaw" so restarting openclaw kills both.
106
+ console.log(" Device is now paired (no restart needed — bind mount is live)");
107
+ } catch (e) {
108
+ console.log(" Warning: could not sync identity: " + e.message);
109
+ }
110
+ } else {
111
+ // Scenario 1: Fresh install — no identity in container yet.
112
+ // Inject the pre-paired identity that setup-config.js generated.
113
+ if (!fs.existsSync(IDENTITY_FILE)) {
114
+ console.log(" No pre-paired device identity found — skipping");
115
+ process.exit(0);
116
+ }
117
+
118
+ var identity = fs.readFileSync(IDENTITY_FILE, "utf8");
119
+
120
+ try {
121
+ // Get the container ID for docker cp (avoids shell quoting issues on Windows)
122
+ var containerId = exec(COMPOSE + " ps -q openvoiceui");
123
+ if (!containerId) throw new Error("openvoiceui container not found");
124
+
125
+ // Ensure uploads dir exists (use double quotes, not single quotes — Windows compat)
126
+ exec(COMPOSE + ' exec -T openvoiceui mkdir -p /app/runtime/uploads');
127
+
128
+ // Write identity to a temp file and docker cp it in
129
+ // docker cp avoids all shell quoting and stdin piping issues
130
+ var tmpFile = path.join("openclaw-data", ".tmp-device-identity.json");
131
+ fs.writeFileSync(tmpFile, identity);
132
+ exec("docker cp " + JSON.stringify(tmpFile) + " " + containerId + ":" + CONTAINER_PATH);
133
+
134
+ // Clean up temp file
135
+ try { fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ }
136
+
137
+ var deviceId = JSON.parse(identity).deviceId || "unknown";
138
+ console.log(" Injected pre-paired device identity: " + deviceId.slice(0, 16) + "...");
139
+ } catch (e) {
140
+ console.log(" Warning: Could not inject device identity: " + e.message.split("\n")[0]);
141
+ }
142
+ }