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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- 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
|
+
}
|