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,838 @@
1
+ """
2
+ routes/music.py — Music System Blueprint (P2-T4)
3
+
4
+ Extracted from server.py during Phase 2 blueprint split.
5
+ Registers routes:
6
+ GET /music/<filename>
7
+ GET /generated_music/<filename>
8
+ GET /api/music (action: list|play|pause|resume|stop|skip|next|next_up|volume|status|shuffle|sync|confirm)
9
+ POST /api/music/transition (DJ transition pre-queue)
10
+ GET /api/music/transition (check pending transition)
11
+ POST /api/music/upload (upload a track)
12
+ GET /api/music/playlists (CRUD: list playlists with track counts)
13
+ DELETE /api/music/track/<playlist>/<filename> (CRUD: delete a track)
14
+ PUT /api/music/track/<playlist>/<filename>/metadata (CRUD: update track metadata)
15
+ """
16
+
17
+ import json
18
+ import random
19
+ import threading
20
+ import time
21
+ import uuid
22
+ from pathlib import Path
23
+
24
+ from flask import Blueprint, jsonify, request, send_file
25
+ from routes.static_files import _safe_path
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Paths
29
+ # ---------------------------------------------------------------------------
30
+
31
+ from services.paths import MUSIC_DIR, GENERATED_MUSIC_DIR
32
+
33
+ MUSIC_DIR.mkdir(parents=True, exist_ok=True)
34
+ GENERATED_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Shared music state (in-process; single-worker deployments only)
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _music_state_lock = threading.Lock()
41
+
42
+ current_music_state = {
43
+ "playing": False,
44
+ "current_track": None,
45
+ "volume": 0.3, # 0.0 – 1.0
46
+ "queue": [],
47
+ "shuffle": False,
48
+ "track_started_at": None,
49
+ "dj_transition_pending": False,
50
+ "next_track": None,
51
+ # Track reservation — prevents race conditions between tool calls and text detection
52
+ "reserved_track": None,
53
+ "reserved_at": None,
54
+ "reservation_id": None,
55
+ "current_playlist": "library", # 'library' | 'generated'
56
+ }
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Reservation helpers
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def reserve_track(track):
63
+ """Reserve a track the agent has announced; expires after 30 s."""
64
+ with _music_state_lock:
65
+ current_music_state["reserved_track"] = track
66
+ current_music_state["reserved_at"] = time.time()
67
+ current_music_state["reservation_id"] = str(uuid.uuid4())[:8]
68
+ rid = current_music_state["reservation_id"]
69
+ print(f"🎵 Track reserved: {track.get('name', 'Unknown')} (ID: {rid})")
70
+ return rid
71
+
72
+
73
+ def get_reserved_track():
74
+ """Return the reserved track if still valid (30-second window)."""
75
+ with _music_state_lock:
76
+ if not current_music_state.get("reserved_track"):
77
+ return None
78
+ reserved_at = current_music_state.get("reserved_at", 0)
79
+ if time.time() - reserved_at > 30:
80
+ print(f"🎵 Track reservation expired (was {current_music_state['reserved_track'].get('name', 'Unknown')})")
81
+ current_music_state["reserved_track"] = None
82
+ current_music_state["reserved_at"] = None
83
+ current_music_state["reservation_id"] = None
84
+ return None
85
+ return current_music_state["reserved_track"]
86
+
87
+
88
+ def clear_reservation():
89
+ """Clear the active reservation (called when frontend confirms playback)."""
90
+ with _music_state_lock:
91
+ if current_music_state.get("reserved_track"):
92
+ print(f"🎵 Reservation cleared: {current_music_state['reserved_track'].get('name', 'Unknown')}")
93
+ current_music_state["reserved_track"] = None
94
+ current_music_state["reserved_at"] = None
95
+ current_music_state["reservation_id"] = None
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Metadata helpers
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def load_music_metadata():
102
+ """Load library playlist metadata from JSON file."""
103
+ metadata_file = MUSIC_DIR / "music_metadata.json"
104
+ if metadata_file.exists():
105
+ try:
106
+ with open(metadata_file, "r") as f:
107
+ return json.load(f)
108
+ except Exception as e:
109
+ print(f"Error loading music metadata: {e}")
110
+ return {}
111
+
112
+
113
+ def load_generated_music_metadata():
114
+ """Load AI-generated playlist metadata from JSON file."""
115
+ metadata_file = GENERATED_MUSIC_DIR / "generated_metadata.json"
116
+ if metadata_file.exists():
117
+ try:
118
+ with open(metadata_file, "r") as f:
119
+ return json.load(f)
120
+ except Exception as e:
121
+ print(f"Error loading generated music metadata: {e}")
122
+ return {}
123
+
124
+
125
+ def save_music_metadata(metadata):
126
+ """Persist library playlist metadata (atomic write — safe against mid-write crashes)."""
127
+ metadata_file = MUSIC_DIR / "music_metadata.json"
128
+ tmp = metadata_file.with_suffix('.tmp')
129
+ tmp.write_text(json.dumps(metadata, indent=2))
130
+ tmp.replace(metadata_file)
131
+
132
+
133
+ def save_generated_music_metadata(metadata):
134
+ """Persist AI-generated playlist metadata (atomic write — safe against mid-write crashes)."""
135
+ metadata_file = GENERATED_MUSIC_DIR / "generated_metadata.json"
136
+ tmp = metadata_file.with_suffix('.tmp')
137
+ tmp.write_text(json.dumps(metadata, indent=2))
138
+ tmp.replace(metadata_file)
139
+
140
+
141
+ def load_playlist_order(playlist):
142
+ """Load saved track order for the given playlist (list of filenames)."""
143
+ music_dir = GENERATED_MUSIC_DIR if playlist == "generated" else MUSIC_DIR
144
+ order_file = music_dir / "order.json"
145
+ if order_file.exists():
146
+ try:
147
+ with open(order_file, "r") as f:
148
+ return json.load(f)
149
+ except Exception:
150
+ pass
151
+ return []
152
+
153
+
154
+ def save_playlist_order(playlist, order):
155
+ """Persist track order for the given playlist (atomic write)."""
156
+ music_dir = GENERATED_MUSIC_DIR if playlist == "generated" else MUSIC_DIR
157
+ order_file = music_dir / "order.json"
158
+ tmp = order_file.with_suffix('.tmp')
159
+ tmp.write_text(json.dumps(order, indent=2))
160
+ tmp.replace(order_file)
161
+
162
+
163
+ def get_music_files(playlist="library"):
164
+ """
165
+ Return list of track dicts for the given playlist.
166
+ playlist: 'library' | 'generated' | 'spotify'
167
+ Respects saved order from order.json if present.
168
+ """
169
+ if playlist == "spotify":
170
+ return []
171
+
172
+ music_extensions = {".mp3", ".wav", ".ogg", ".m4a", ".webm"}
173
+
174
+ if playlist == "generated":
175
+ music_dir = GENERATED_MUSIC_DIR
176
+ metadata = load_generated_music_metadata()
177
+ url_prefix = "/generated_music/"
178
+ default_artist = "Jam-Bot"
179
+ else:
180
+ music_dir = MUSIC_DIR
181
+ metadata = load_music_metadata()
182
+ url_prefix = "/music/"
183
+ default_artist = "AI DJ"
184
+
185
+ files = []
186
+ for f in music_dir.iterdir():
187
+ if f.is_file() and f.suffix.lower() in music_extensions:
188
+ track_info = {
189
+ "filename": f.name,
190
+ "name": f.stem,
191
+ "size_bytes": f.stat().st_size,
192
+ "format": f.suffix.lower()[1:],
193
+ "url_prefix": url_prefix,
194
+ "playlist": playlist,
195
+ }
196
+ if f.name in metadata:
197
+ meta = metadata[f.name]
198
+ track_info.update({
199
+ "title": meta.get("title", f.stem),
200
+ "artist": meta.get("artist", default_artist),
201
+ "duration_seconds": meta.get("duration_seconds", 120),
202
+ "description": meta.get("description", ""),
203
+ "phone_number": meta.get("phone_number"),
204
+ "ad_copy": meta.get("ad_copy", ""),
205
+ "fun_facts": meta.get("fun_facts", []),
206
+ "genre": meta.get("genre", "Unknown"),
207
+ "energy": meta.get("energy", "medium"),
208
+ "dj_intro_hints": meta.get("dj_intro_hints", []),
209
+ })
210
+ else:
211
+ track_info.update({
212
+ "title": f.stem,
213
+ "artist": default_artist,
214
+ "duration_seconds": 120,
215
+ "description": "A track from the music library!" if playlist == "library" else "An AI-generated original!",
216
+ "phone_number": None,
217
+ "ad_copy": "",
218
+ "fun_facts": [],
219
+ "genre": "Unknown",
220
+ "energy": "medium",
221
+ "dj_intro_hints": [],
222
+ })
223
+ files.append(track_info)
224
+
225
+ # Apply saved order if present; unordered tracks fall to end alphabetically
226
+ saved_order = load_playlist_order(playlist)
227
+ if saved_order:
228
+ order_index = {name: i for i, name in enumerate(saved_order)}
229
+ fallback = len(saved_order)
230
+ files.sort(key=lambda x: (order_index.get(x["filename"], fallback), x["name"].lower()))
231
+ else:
232
+ files.sort(key=lambda x: x["name"].lower())
233
+ return files
234
+
235
+
236
+ def _build_dj_hints(track):
237
+ """Build a DJ hints string from track metadata."""
238
+ title = track.get("title", track["name"])
239
+ description = track.get("description", "")
240
+ phone = track.get("phone_number")
241
+ ad_copy = track.get("ad_copy", "")
242
+ fun_facts = track.get("fun_facts", [])
243
+ duration = track.get("duration_seconds", 120)
244
+ duration_str = f"{int(duration // 60)}:{int(duration % 60):02d}"
245
+
246
+ hints = f"Title: {title}. Duration: {duration_str}."
247
+ if description:
248
+ hints += f" About: {description}"
249
+ if phone:
250
+ hints += f" Call: {phone}"
251
+ if ad_copy:
252
+ hints += f" Ad: {ad_copy}"
253
+ if fun_facts:
254
+ hints += f" Fun fact: {random.choice(fun_facts)}"
255
+ return hints
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Blueprint
259
+ # ---------------------------------------------------------------------------
260
+
261
+ music_bp = Blueprint("music", __name__)
262
+
263
+ # MIME type map shared by file-serving routes
264
+ _AUDIO_MIME_TYPES = {
265
+ ".mp3": "audio/mpeg",
266
+ ".wav": "audio/wav",
267
+ ".ogg": "audio/ogg",
268
+ ".m4a": "audio/mp4",
269
+ ".webm": "audio/webm",
270
+ }
271
+
272
+
273
+ @music_bp.route("/music/<filename>")
274
+ def serve_music_file(filename):
275
+ """Serve library music files."""
276
+ music_path = _safe_path(MUSIC_DIR, filename)
277
+ if music_path is None or not music_path.exists():
278
+ return jsonify({"error": "Track not found"}), 404
279
+ mime_type = _AUDIO_MIME_TYPES.get(music_path.suffix.lower(), "audio/mpeg")
280
+ return send_file(music_path, mimetype=mime_type)
281
+
282
+
283
+ @music_bp.route("/generated_music/<filename>")
284
+ def serve_generated_music_file(filename):
285
+ """Serve AI-generated music files."""
286
+ music_path = _safe_path(GENERATED_MUSIC_DIR, filename)
287
+ if music_path is None or not music_path.exists():
288
+ return jsonify({"error": "Generated track not found"}), 404
289
+ mime_type = _AUDIO_MIME_TYPES.get(music_path.suffix.lower(), "audio/mpeg")
290
+ return send_file(music_path, mimetype=mime_type)
291
+
292
+
293
+ @music_bp.route("/api/music", methods=["GET"])
294
+ def handle_music():
295
+ """
296
+ All-in-one music endpoint.
297
+ Query params:
298
+ action : list | play | pause | resume | stop | skip | next | next_up |
299
+ volume | status | shuffle | sync | confirm
300
+ track : track name or filename (for play)
301
+ volume : 0-100 (for volume action)
302
+ playlist : 'library' | 'generated'
303
+ """
304
+ action = request.args.get("action", "list")
305
+ track_param = request.args.get("track", "")
306
+ volume_param = request.args.get("volume", "")
307
+ playlist = request.args.get(
308
+ "playlist", current_music_state.get("current_playlist", "library")
309
+ )
310
+
311
+ if playlist in ("library", "generated", "spotify"):
312
+ current_music_state["current_playlist"] = playlist
313
+
314
+ # ── SPOTIFY ───────────────────────────────────────────────────────────
315
+ # Handle before get_music_files — Spotify has no local files
316
+ if action == "spotify":
317
+ track = request.args.get("track", "Unknown Track")
318
+ artist = request.args.get("artist", "Spotify")
319
+ album = request.args.get("album", "")
320
+ current_music_state["current_playlist"] = "spotify"
321
+ current_music_state["playing"] = True
322
+ current_music_state["track_started_at"] = time.time()
323
+ spotify_track = {
324
+ "title": track,
325
+ "name": track,
326
+ "artist": artist,
327
+ "album": album,
328
+ "playlist": "spotify",
329
+ "source": "spotify",
330
+ "filename": None,
331
+ }
332
+ current_music_state["current_track"] = spotify_track
333
+ print(f"🎵 Spotify mode: '{track}' by {artist}")
334
+ return jsonify({
335
+ "action": "spotify",
336
+ "track": spotify_track,
337
+ "playlist": "spotify",
338
+ "source": "spotify",
339
+ "response": f"Now streaming '{track}' by {artist} from Spotify.",
340
+ })
341
+
342
+ try:
343
+ music_files = get_music_files(playlist)
344
+
345
+ # ── LIST ──────────────────────────────────────────────────────────
346
+ if action == "list":
347
+ if playlist == "spotify":
348
+ return jsonify({
349
+ "tracks": [],
350
+ "count": 0,
351
+ "playlist": "spotify",
352
+ "available_playlists": ["library", "generated", "spotify"],
353
+ "source": "spotify",
354
+ "response": "Spotify streaming mode. Ask me to play any song, album, or playlist on Spotify.",
355
+ })
356
+ if not music_files:
357
+ return jsonify({
358
+ "tracks": [],
359
+ "count": 0,
360
+ "playlist": playlist,
361
+ "available_playlists": ["library", "generated", "spotify"],
362
+ "response": "I don't have any music yet! Upload some MP3s to my music folder and I'll spin them for you.",
363
+ })
364
+ track_names = [t["name"] for t in music_files]
365
+ return jsonify({
366
+ "tracks": music_files,
367
+ "count": len(music_files),
368
+ "playlist": playlist,
369
+ "available_playlists": ["library", "generated", "spotify"],
370
+ "response": (
371
+ f"I've got {len(music_files)} track{'s' if len(music_files) != 1 else ''} ready to spin: "
372
+ f"{', '.join(track_names[:5])}{'...' if len(track_names) > 5 else ''}"
373
+ ),
374
+ })
375
+
376
+ # ── PLAY ──────────────────────────────────────────────────────────
377
+ elif action == "play":
378
+ if not music_files:
379
+ return jsonify({"action": "error", "response": "No music files! My DJ booth is empty. Get me some tunes!"})
380
+
381
+ selected = None
382
+ if track_param:
383
+ track_lower = track_param.lower()
384
+ # Normalize smart quotes to ASCII for matching
385
+ _quote_map = str.maketrans({'\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"'})
386
+ track_norm = track_lower.translate(_quote_map)
387
+ for t in music_files:
388
+ t_name = t["name"].lower()
389
+ t_file = t["filename"].lower()
390
+ t_title = t.get("title", "").lower().translate(_quote_map)
391
+ if (track_norm in t_name
392
+ or track_norm in t_file
393
+ or track_norm in t_title):
394
+ selected = t
395
+ break
396
+ if not selected:
397
+ return jsonify({
398
+ "action": "error",
399
+ "response": f"Can't find a track matching '{track_param}'. Try 'list music' to see what I have.",
400
+ })
401
+ print(f"🎵 PLAY matched: query='{track_param}' → file='{selected['filename']}' title='{selected.get('title', '')}'")
402
+ else:
403
+ selected = random.choice(music_files)
404
+
405
+ current_music_state["playing"] = True
406
+ current_music_state["current_track"] = selected
407
+ current_music_state["track_started_at"] = time.time()
408
+ reservation_id = reserve_track(selected)
409
+
410
+ title = selected.get("title", selected["name"])
411
+ description = selected.get("description", "")
412
+ duration = selected.get("duration_seconds", 120)
413
+
414
+ return jsonify({
415
+ "action": "play",
416
+ "track": selected,
417
+ "url": f"{selected.get('url_prefix', '/music/')}{selected['filename']}",
418
+ "playlist": playlist,
419
+ "duration_seconds": duration,
420
+ "dj_hints": _build_dj_hints(selected),
421
+ "reservation_id": reservation_id,
422
+ "response": f"Now playing '{title}'! {description if description else 'Lets gooo!'}",
423
+ })
424
+
425
+ # ── PAUSE ─────────────────────────────────────────────────────────
426
+ elif action == "pause":
427
+ current_music_state["playing"] = False
428
+ track_name = (current_music_state.get("current_track") or {}).get("name", "the music")
429
+ return jsonify({"action": "pause", "response": f"Pausing {track_name}. Taking a breather."})
430
+
431
+ # ── RESUME ────────────────────────────────────────────────────────
432
+ elif action == "resume":
433
+ current_music_state["playing"] = True
434
+ track_name = (current_music_state.get("current_track") or {}).get("name", "the music")
435
+ return jsonify({"action": "resume", "response": f"Resuming {track_name}. Back on the air!"})
436
+
437
+ # ── STOP ──────────────────────────────────────────────────────────
438
+ elif action == "stop":
439
+ current_music_state["playing"] = False
440
+ current_music_state["current_track"] = None
441
+ return jsonify({"action": "stop", "response": "Music stopped. Silence... beautiful, terrible silence."})
442
+
443
+ # ── SKIP / NEXT ───────────────────────────────────────────────────
444
+ elif action in ("skip", "next"):
445
+ if not music_files:
446
+ return jsonify({"action": "error", "response": "No music to skip to!"})
447
+
448
+ current_name = (current_music_state.get("current_track") or {}).get("name")
449
+ available = [t for t in music_files if t["name"] != current_name] or music_files
450
+ selected = random.choice(available)
451
+
452
+ current_music_state["playing"] = True
453
+ current_music_state["current_track"] = selected
454
+ current_music_state["track_started_at"] = time.time()
455
+ reservation_id = reserve_track(selected)
456
+
457
+ title = selected.get("title", selected["name"])
458
+ description = selected.get("description", "")
459
+ duration = selected.get("duration_seconds", 120)
460
+
461
+ return jsonify({
462
+ "action": "play",
463
+ "track": selected,
464
+ "url": f"{selected.get('url_prefix', '/music/')}{selected['filename']}",
465
+ "playlist": playlist,
466
+ "duration_seconds": duration,
467
+ "dj_hints": _build_dj_hints(selected),
468
+ "reservation_id": reservation_id,
469
+ "response": f"Skipping! Next up: '{title}'! {description if description else ''}",
470
+ })
471
+
472
+ # ── NEXT_UP ───────────────────────────────────────────────────────
473
+ elif action == "next_up":
474
+ if not music_files:
475
+ return jsonify({"action": "error", "response": "No tracks available!"})
476
+
477
+ current_name = (current_music_state.get("current_track") or {}).get("name")
478
+ available = [t for t in music_files if t["name"] != current_name] or music_files
479
+ selected = random.choice(available)
480
+ current_music_state["next_track"] = selected
481
+
482
+ title = selected.get("title", selected["name"])
483
+ duration = selected.get("duration_seconds", 120)
484
+
485
+ return jsonify({
486
+ "action": "next_up",
487
+ "track": selected,
488
+ "duration_seconds": duration,
489
+ "dj_hints": _build_dj_hints(selected),
490
+ "response": f"Coming up next: '{title}'!",
491
+ })
492
+
493
+ # ── VOLUME ────────────────────────────────────────────────────────
494
+ elif action == "volume":
495
+ if not volume_param:
496
+ current_vol = int(current_music_state["volume"] * 100)
497
+ return jsonify({"action": "volume", "volume": current_vol, "response": f"Volume is at {current_vol}%."})
498
+
499
+ try:
500
+ new_vol = max(0, min(100, int(volume_param)))
501
+ current_music_state["volume"] = new_vol / 100
502
+ if new_vol >= 80:
503
+ comment = "Cranking it up! Let's make some noise!"
504
+ elif new_vol >= 50:
505
+ comment = "Nice and loud. I like it."
506
+ elif new_vol >= 20:
507
+ comment = "Background vibes. Got it."
508
+ else:
509
+ comment = "Barely a whisper. You sure you want music?"
510
+ return jsonify({"action": "volume", "volume": new_vol, "response": f"Volume set to {new_vol}%. {comment}"})
511
+ except ValueError:
512
+ return jsonify({"action": "error", "response": f"'{volume_param}' isn't a valid volume. Give me a number 0-100."})
513
+
514
+ # ── STATUS ────────────────────────────────────────────────────────
515
+ elif action == "status":
516
+ track = current_music_state.get("current_track")
517
+ playing = current_music_state.get("playing", False)
518
+ vol = int(current_music_state["volume"] * 100)
519
+ started_at = current_music_state.get("track_started_at")
520
+
521
+ # Spotify mode — no local file, no timeline
522
+ if track and track.get("source") == "spotify":
523
+ title = track.get("title", "Unknown")
524
+ artist = track.get("artist", "")
525
+ artist_str = f" by {artist}" if artist else ""
526
+ return jsonify({
527
+ "action": "status",
528
+ "playing": playing,
529
+ "track": track,
530
+ "source": "spotify",
531
+ "volume": vol,
532
+ "response": f"{'Streaming' if playing else 'Paused'}:'{title}'{artist_str} on Spotify.",
533
+ })
534
+
535
+ if track and playing:
536
+ duration = track.get("duration_seconds", 120)
537
+ elapsed = time.time() - started_at if started_at else 0
538
+ remaining = max(0, duration - elapsed)
539
+ title = track.get("title", track["name"])
540
+ return jsonify({
541
+ "action": "status",
542
+ "playing": True,
543
+ "track": track,
544
+ "volume": vol,
545
+ "duration_seconds": duration,
546
+ "elapsed_seconds": int(elapsed),
547
+ "remaining_seconds": int(remaining),
548
+ "response": f"Now playing: '{title}' at {vol}% volume. About {int(remaining)}s remaining.",
549
+ })
550
+ elif track:
551
+ title = track.get("title", track["name"])
552
+ return jsonify({
553
+ "action": "status",
554
+ "playing": False,
555
+ "track": track,
556
+ "volume": vol,
557
+ "response": f"'{title}' is paused. Volume at {vol}%.",
558
+ })
559
+ else:
560
+ return jsonify({
561
+ "action": "status",
562
+ "playing": False,
563
+ "track": None,
564
+ "volume": vol,
565
+ "response": "Nothing playing right now. Say 'play music' to get the party started!",
566
+ })
567
+
568
+ # ── SHUFFLE ───────────────────────────────────────────────────────
569
+ elif action == "shuffle":
570
+ current_music_state["shuffle"] = not current_music_state["shuffle"]
571
+ state = "on" if current_music_state["shuffle"] else "off"
572
+ return jsonify({
573
+ "action": "shuffle",
574
+ "shuffle": current_music_state["shuffle"],
575
+ "response": f"Shuffle is {state}. {'Random chaos enabled!' if current_music_state['shuffle'] else 'Back to order.'}",
576
+ })
577
+
578
+ # ── SYNC ──────────────────────────────────────────────────────────
579
+ elif action == "sync":
580
+ reserved = get_reserved_track()
581
+ if reserved:
582
+ title = reserved.get("title", reserved["name"])
583
+ duration = reserved.get("duration_seconds", 120)
584
+ print(f"🎵 SYNC returning reserved track: {title}")
585
+ return jsonify({
586
+ "action": "play",
587
+ "track": reserved,
588
+ "url": f"/music/{reserved['filename']}",
589
+ "duration_seconds": duration,
590
+ "reservation_id": current_music_state.get("reservation_id"),
591
+ "synced": True,
592
+ "response": f"Synced to '{title}'",
593
+ })
594
+
595
+ track = current_music_state.get("current_track")
596
+ if track and current_music_state.get("playing"):
597
+ title = track.get("title", track["name"])
598
+ duration = track.get("duration_seconds", 120)
599
+ print(f"🎵 SYNC returning current track: {title}")
600
+ return jsonify({
601
+ "action": "play",
602
+ "track": track,
603
+ "url": f"/music/{track['filename']}",
604
+ "duration_seconds": duration,
605
+ "synced": True,
606
+ "response": f"Synced to '{title}'",
607
+ })
608
+
609
+ print("🎵 SYNC: No track to sync")
610
+ return jsonify({"action": "none", "synced": True, "response": "No track to sync to"})
611
+
612
+ # ── CONFIRM ───────────────────────────────────────────────────────
613
+ elif action == "confirm":
614
+ res_id = request.args.get("reservation_id", "")
615
+ current_res_id = current_music_state.get("reservation_id", "")
616
+ if res_id and res_id == current_res_id:
617
+ track = current_music_state.get("reserved_track")
618
+ title = track.get("title", track["name"]) if track else "Unknown"
619
+ clear_reservation()
620
+ print(f"🎵 Playback confirmed for: {title}")
621
+ return jsonify({"action": "confirmed", "response": f"Playback confirmed: {title}"})
622
+ else:
623
+ return jsonify({"action": "error", "response": "Invalid or expired reservation"})
624
+
625
+ else:
626
+ return jsonify({
627
+ "action": "error",
628
+ "response": f"Unknown action '{action}'. Try: list, play, pause, stop, skip, volume, status, sync",
629
+ })
630
+
631
+ except Exception as e:
632
+ print(f"Music error: {e}")
633
+ return jsonify({"action": "error", "response": "Music playback error"})
634
+
635
+
636
+ @music_bp.route("/api/music/transition", methods=["POST", "GET"])
637
+ def handle_dj_transition():
638
+ """
639
+ DJ transition endpoint.
640
+ POST: Frontend signals song is ending; pre-queue next track.
641
+ GET : Agent polls for pending transition.
642
+ """
643
+ if request.method == "POST":
644
+ data = request.get_json() or {}
645
+ remaining = data.get("remaining_seconds", 10)
646
+
647
+ music_files = get_music_files()
648
+ current_name = (current_music_state.get("current_track") or {}).get("name")
649
+ available = [t for t in music_files if t["name"] != current_name] or music_files
650
+
651
+ if available:
652
+ selected = random.choice(available)
653
+ current_music_state["next_track"] = selected
654
+ current_music_state["dj_transition_pending"] = True
655
+
656
+ title = selected.get("title", selected["name"])
657
+ description = selected.get("description", "")
658
+ fun_facts = selected.get("fun_facts", [])
659
+
660
+ return jsonify({
661
+ "status": "transition_queued",
662
+ "next_track": selected,
663
+ "remaining_seconds": remaining,
664
+ "response": f"Coming up next: '{title}'! {random.choice(fun_facts) if fun_facts else description}",
665
+ })
666
+ else:
667
+ return jsonify({"status": "no_tracks", "response": "No more tracks to play!"})
668
+
669
+ else: # GET
670
+ if current_music_state.get("dj_transition_pending") and current_music_state.get("next_track"):
671
+ track = current_music_state["next_track"]
672
+ current_music_state["dj_transition_pending"] = False
673
+
674
+ title = track.get("title", track["name"])
675
+ duration = track.get("duration_seconds", 120)
676
+
677
+ return jsonify({
678
+ "transition_pending": True,
679
+ "next_track": track,
680
+ "dj_hints": _build_dj_hints(track),
681
+ "response": f"Hey! Song's ending soon. Coming up next: '{title}'!",
682
+ })
683
+ else:
684
+ return jsonify({"transition_pending": False, "response": "No transition pending."})
685
+
686
+
687
+ @music_bp.route("/api/music/upload", methods=["POST"])
688
+ def upload_music():
689
+ """Upload a music file to the library playlist."""
690
+ if "file" not in request.files:
691
+ return jsonify({"error": "No file provided"}), 400
692
+
693
+ file = request.files["file"]
694
+ if not file.filename:
695
+ return jsonify({"error": "No filename"}), 400
696
+
697
+ allowed_extensions = {".mp3", ".wav", ".ogg", ".m4a", ".webm"}
698
+ ext = Path(file.filename).suffix.lower()
699
+ if ext not in allowed_extensions:
700
+ return jsonify({"error": f"Invalid format. Allowed: {', '.join(allowed_extensions)}"}), 400
701
+
702
+ safe_name = "".join(c for c in Path(file.filename).stem if c.isalnum() or c in " _-")
703
+ safe_name = safe_name[:50] + ext
704
+
705
+ save_path = MUSIC_DIR / safe_name
706
+ file.save(save_path)
707
+
708
+ return jsonify({
709
+ "status": "success",
710
+ "filename": safe_name,
711
+ "response": f"Track '{safe_name}' uploaded! Ready to spin.",
712
+ })
713
+
714
+
715
+ # ---------------------------------------------------------------------------
716
+ # Playlist CRUD endpoints (P2-T4 requirement)
717
+ # ---------------------------------------------------------------------------
718
+
719
+ @music_bp.route("/api/music/playlists", methods=["GET"])
720
+ def list_playlists():
721
+ """List all available playlists with track counts and total sizes."""
722
+ playlists = []
723
+ for name, music_dir in (("library", MUSIC_DIR), ("generated", GENERATED_MUSIC_DIR)):
724
+ music_extensions = {".mp3", ".wav", ".ogg", ".m4a", ".webm"}
725
+ tracks = [f for f in music_dir.iterdir() if f.is_file() and f.suffix.lower() in music_extensions]
726
+ playlists.append({
727
+ "name": name,
728
+ "track_count": len(tracks),
729
+ "total_size_bytes": sum(f.stat().st_size for f in tracks),
730
+ "active": current_music_state.get("current_playlist") == name,
731
+ })
732
+ # Spotify is a virtual playlist — no local files
733
+ playlists.append({
734
+ "name": "spotify",
735
+ "track_count": None,
736
+ "total_size_bytes": None,
737
+ "active": current_music_state.get("current_playlist") == "spotify",
738
+ "source": "spotify",
739
+ "description": "Stream any song, album, or playlist from Spotify",
740
+ })
741
+ return jsonify({"playlists": playlists})
742
+
743
+
744
+ @music_bp.route("/api/music/track/<playlist>/<filename>", methods=["DELETE"])
745
+ def delete_track(playlist, filename):
746
+ """Delete a track from a playlist."""
747
+ if playlist == "generated":
748
+ music_dir = GENERATED_MUSIC_DIR
749
+ load_meta = load_generated_music_metadata
750
+ save_meta = save_generated_music_metadata
751
+ elif playlist == "library":
752
+ music_dir = MUSIC_DIR
753
+ load_meta = load_music_metadata
754
+ save_meta = save_music_metadata
755
+ else:
756
+ return jsonify({"error": f"Unknown playlist '{playlist}'"}), 400
757
+
758
+ safe_filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
759
+ track_path = music_dir / safe_filename
760
+
761
+ if not track_path.exists():
762
+ return jsonify({"error": "Track not found"}), 404
763
+
764
+ track_path.unlink()
765
+
766
+ # Remove from metadata if present
767
+ metadata = load_meta()
768
+ if safe_filename in metadata:
769
+ del metadata[safe_filename]
770
+ save_meta(metadata)
771
+
772
+ # Clear from state if this was the active/reserved track
773
+ current = current_music_state.get("current_track") or {}
774
+ if current.get("filename") == safe_filename:
775
+ current_music_state["current_track"] = None
776
+ current_music_state["playing"] = False
777
+ reserved = current_music_state.get("reserved_track") or {}
778
+ if reserved.get("filename") == safe_filename:
779
+ clear_reservation()
780
+
781
+ return jsonify({"status": "deleted", "filename": safe_filename, "playlist": playlist})
782
+
783
+
784
+ @music_bp.route("/api/music/playlist/<playlist>/order", methods=["GET", "POST"])
785
+ def playlist_order(playlist):
786
+ """
787
+ GET : Return the saved track order for the playlist (list of filenames).
788
+ POST: Save a new track order. Body: {"order": ["file1.mp3", "file2.mp3", ...]}
789
+ """
790
+ if playlist not in ("library", "generated"):
791
+ return jsonify({"error": f"Unknown playlist '{playlist}'"}), 400
792
+
793
+ if request.method == "GET":
794
+ return jsonify({"playlist": playlist, "order": load_playlist_order(playlist)})
795
+
796
+ data = request.get_json()
797
+ if not data or not isinstance(data.get("order"), list):
798
+ return jsonify({"error": "Body must be JSON with 'order' array of filenames"}), 400
799
+
800
+ order = [str(f) for f in data["order"]]
801
+ save_playlist_order(playlist, order)
802
+ return jsonify({"status": "saved", "playlist": playlist, "order": order})
803
+
804
+
805
+ @music_bp.route("/api/music/track/<playlist>/<filename>/metadata", methods=["PUT"])
806
+ def update_track_metadata(playlist, filename):
807
+ """Update metadata for a track (title, artist, description, etc.)."""
808
+ if playlist == "generated":
809
+ load_meta = load_generated_music_metadata
810
+ save_meta = save_generated_music_metadata
811
+ music_dir = GENERATED_MUSIC_DIR
812
+ elif playlist == "library":
813
+ load_meta = load_music_metadata
814
+ save_meta = save_music_metadata
815
+ music_dir = MUSIC_DIR
816
+ else:
817
+ return jsonify({"error": f"Unknown playlist '{playlist}'"}), 400
818
+
819
+ safe_filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
820
+ track_path = music_dir / safe_filename
821
+ if not track_path.exists():
822
+ return jsonify({"error": "Track not found"}), 404
823
+
824
+ data = request.get_json()
825
+ if not data:
826
+ return jsonify({"error": "No JSON body provided"}), 400
827
+
828
+ allowed_fields = {"title", "artist", "description", "duration_seconds", "phone_number",
829
+ "ad_copy", "fun_facts", "genre", "energy", "dj_intro_hints"}
830
+ metadata = load_meta()
831
+ entry = metadata.get(safe_filename, {})
832
+ for field in allowed_fields:
833
+ if field in data:
834
+ entry[field] = data[field]
835
+ metadata[safe_filename] = entry
836
+ save_meta(metadata)
837
+
838
+ return jsonify({"status": "updated", "filename": safe_filename, "playlist": playlist, "metadata": entry})