Shackless commited on
Commit
a2e33f0
·
1 Parent(s): 20251e2

rename voices, implement better UI to search voices (#4)

Browse files

* rename voices, implement better UI to search voices
* add output format param to UI, clarify when streaming is (not) available
* fixes #2 docker: ensure non-root user owns /app dir to preventing permission errors

Files changed (7) hide show
  1. Dockerfile +2 -2
  2. app/routes.py +9 -1
  3. app/services/tts.py +25 -12
  4. pyproject.toml +1 -1
  5. static/css/style.css +459 -239
  6. static/js/app.js +399 -34
  7. templates/index.html +88 -7
Dockerfile CHANGED
@@ -44,8 +44,8 @@ COPY --chown=pockettts:pockettts templates/ ./templates/
44
  COPY --chown=pockettts:pockettts voices/ ./voices/
45
  COPY --chown=pockettts:pockettts server.py ./
46
 
47
- # Create logs directory
48
- RUN mkdir -p /app/logs && chown pockettts:pockettts /app/logs
49
 
50
  # Create HuggingFace cache directory (for volume mount)
51
  RUN mkdir -p /home/pockettts/.cache/huggingface && \
 
44
  COPY --chown=pockettts:pockettts voices/ ./voices/
45
  COPY --chown=pockettts:pockettts server.py ./
46
 
47
+ # Create logs directory, and ensure app directory is owned by user
48
+ RUN chown pockettts:pockettts /app && mkdir -p /app/logs && chown pockettts:pockettts /app/logs
49
 
50
  # Create HuggingFace cache directory (for volume mount)
51
  RUN mkdir -p /home/pockettts/.cache/huggingface && \
app/routes.py CHANGED
@@ -75,7 +75,15 @@ def list_voices():
75
  return jsonify(
76
  {
77
  'object': 'list',
78
- 'data': [{'id': v['id'], 'name': v['name'], 'object': 'voice'} for v in voices],
 
 
 
 
 
 
 
 
79
  }
80
  )
81
 
 
75
  return jsonify(
76
  {
77
  'object': 'list',
78
+ 'data': [
79
+ {
80
+ 'id': v['id'],
81
+ 'name': v['name'],
82
+ 'object': 'voice',
83
+ 'type': v.get('type', 'builtin'),
84
+ }
85
+ for v in voices
86
+ ],
87
  }
88
  )
89
 
app/services/tts.py CHANGED
@@ -289,24 +289,37 @@ class TTSService:
289
  """
290
  voices = []
291
 
292
- # Built-in voices
293
- for voice in Config.BUILTIN_VOICES:
 
294
  voices.append({'id': voice, 'name': voice.capitalize(), 'type': 'builtin'})
295
 
296
  # Custom voices from directory
 
297
  if self.voices_dir and os.path.isdir(self.voices_dir):
 
 
 
 
298
  for ext in Config.VOICE_EXTENSIONS:
299
- pattern = f'*{ext}'
300
- voice_dir = Path(self.voices_dir)
301
- for voice_file in voice_dir.glob(pattern):
302
- voices.append(
303
- {
304
- 'id': voice_file.name,
305
- 'name': f'Custom: {voice_file.stem}',
306
- 'type': 'custom',
307
- }
308
- )
 
 
 
 
 
 
309
 
 
310
  return voices
311
 
312
 
 
289
  """
290
  voices = []
291
 
292
+ # Built-in voices (sorted alphabetically)
293
+ builtin_sorted = sorted(Config.BUILTIN_VOICES)
294
+ for voice in builtin_sorted:
295
  voices.append({'id': voice, 'name': voice.capitalize(), 'type': 'builtin'})
296
 
297
  # Custom voices from directory
298
+ custom_voices = []
299
  if self.voices_dir and os.path.isdir(self.voices_dir):
300
+ voice_dir = Path(self.voices_dir)
301
+
302
+ # Collect all valid files
303
+ voice_files = []
304
  for ext in Config.VOICE_EXTENSIONS:
305
+ voice_files.extend(voice_dir.glob(f'*{ext}'))
306
+
307
+ # Sort alphabetically by filename
308
+ voice_files.sort(key=lambda f: f.name.lower())
309
+
310
+ for voice_file in voice_files:
311
+ # Format name: "bobby_mcfern" -> "Bobby Mcfern"
312
+ clean_name = voice_file.stem.replace('_', ' ').replace('-', ' ').title()
313
+
314
+ custom_voices.append(
315
+ {
316
+ 'id': voice_file.name,
317
+ 'name': clean_name,
318
+ 'type': 'custom',
319
+ }
320
+ )
321
 
322
+ voices.extend(custom_voices)
323
  return voices
324
 
325
 
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ license = {text = "MIT"}
4
  name = "pocket-tts-openai-server"
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
- version = "2.1.0"
8
 
9
  dependencies = [
10
  "flask>=3.0.0",
 
4
  name = "pocket-tts-openai-server"
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
+ version = "2.2.0"
8
 
9
  dependencies = [
10
  "flask>=3.0.0",
static/css/style.css CHANGED
@@ -1,419 +1,639 @@
1
  :root {
2
- --bg-color: #0d1117;
3
- --card-bg: #161b22;
4
- --accent-color: #58a6ff;
5
- --accent-hover: #1f6feb;
6
- --text-primary: #c9d1d9;
7
- --text-secondary: #8b949e;
8
- --border-color: #30363d;
9
- --input-bg: #0d1117;
10
- --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
11
- --glass-bg: rgba(22, 27, 34, 0.7);
12
- --glass-border: rgba(48, 54, 61, 0.5);
 
 
13
  }
14
 
15
  * {
16
- box-sizing: border-box;
17
- margin: 0;
18
- padding: 0;
19
  }
20
 
21
  body {
22
- font-family: var(--font-family);
23
- background-color: var(--bg-color);
24
- color: var(--text-primary);
25
- display: flex;
26
- justify-content: center;
27
- align-items: center;
28
- min-height: 100vh;
29
- background-image: radial-gradient(circle at 50% 0%, #1f2530 0%, #0d1117 100%);
30
  }
31
 
32
  .container {
33
- width: 100%;
34
- max-width: 800px;
35
- padding: 2rem;
36
- background: var(--glass-bg);
37
- backdrop-filter: blur(12px);
38
- border: 1px solid var(--glass-border);
39
- border-radius: 16px;
40
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
41
- animation: fadeIn 0.5s ease-out;
42
  }
43
 
44
  @keyframes fadeIn {
45
- from { opacity: 0; transform: translateY(20px); }
46
- to { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
47
  }
48
 
49
  header {
50
- text-align: center;
51
- margin-bottom: 2rem;
52
  }
53
 
54
  .logo {
55
- max-width: 120px;
56
- margin-bottom: 1rem;
57
- filter: drop-shadow(0 0 10px rgba(88, 166, 255, 0.3));
58
  }
59
 
60
  h1 {
61
- font-size: 2rem;
62
- font-weight: 700;
63
- color: var(--text-primary);
64
- margin-bottom: 0.5rem;
65
- background: linear-gradient(90deg, #58a6ff, #a371f7);
66
- -webkit-background-clip: text;
67
- -webkit-text-fill-color: transparent;
68
  }
69
 
70
  .subtitle {
71
- color: var(--text-secondary);
72
- font-size: 0.95rem;
73
  }
74
 
75
  .control-group {
76
- margin-bottom: 1.5rem;
 
77
  }
78
 
79
  label {
80
- display: block;
81
- margin-bottom: 0.5rem;
82
- font-weight: 500;
83
- color: var(--text-secondary);
84
- font-size: 0.9rem;
85
  }
86
 
87
- input[type="text"],
88
  textarea,
89
  select {
90
- width: 100%;
91
- padding: 0.75rem 1rem;
92
- background-color: var(--input-bg);
93
- border: 1px solid var(--border-color);
94
- border-radius: 8px;
95
- color: var(--text-primary);
96
- font-family: inherit;
97
- font-size: 1rem;
98
- transition: border-color 0.2s, box-shadow 0.2s;
99
- }
100
-
101
- input[type="file"] {
102
- width: 100%;
103
- padding: 0.5rem;
104
- background-color: var(--input-bg);
105
- border: 1px dashed var(--border-color);
106
- border-radius: 8px;
107
- color: var(--text-secondary);
108
- cursor: pointer;
 
 
109
  }
110
 
111
  input:focus,
112
  textarea:focus,
113
  select:focus {
114
- outline: none;
115
- border-color: var(--accent-color);
116
- box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
117
  }
118
 
119
  textarea {
120
- min-height: 120px;
121
- resize: vertical;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
 
124
  .btn-primary {
125
- display: block;
126
- width: 100%;
127
- padding: 1rem;
128
- border: none;
129
- border-radius: 8px;
130
- background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
131
- color: white;
132
- font-weight: 600;
133
- font-size: 1.1rem;
134
- cursor: pointer;
135
- transition: transform 0.1s, opacity 0.2s;
136
- box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
 
 
137
  }
138
 
139
  .btn-primary:hover {
140
- opacity: 0.9;
141
  }
142
 
143
  .btn-primary:active {
144
- transform: scale(0.98);
145
  }
146
 
147
  .btn-primary:disabled {
148
- background: var(--border-color);
149
- cursor: not-allowed;
150
- opacity: 0.7;
151
- box-shadow: none;
152
  }
153
 
154
  .output-section {
155
- margin-top: 2rem;
156
- padding-top: 2rem;
157
- border-top: 1px solid var(--border-color);
158
- text-align: center;
159
- display: none; /* Hidden by default */
160
  }
161
 
162
  .output-section.active {
163
- display: block;
164
- animation: slideDown 0.3s ease-out;
165
  }
166
 
167
  @keyframes slideDown {
168
- from { opacity: 0; transform: translateY(-10px); }
169
- to { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
170
  }
171
 
172
  audio {
173
- width: 100%;
174
- margin-bottom: 1rem;
175
- border-radius: 8px;
176
  }
177
 
178
  .download-link {
179
- display: inline-block;
180
- padding: 0.5rem 1rem;
181
- color: var(--accent-color);
182
- text-decoration: none;
183
- border: 1px solid var(--accent-color);
184
- border-radius: 6px;
185
- font-size: 0.9rem;
186
- transition: all 0.2s;
187
  }
188
 
189
  .download-link:hover {
190
- background: rgba(88, 166, 255, 0.1);
191
  }
192
 
193
  /* Spinner */
194
  .spinner {
195
- display: inline-block;
196
- width: 1.5rem;
197
- height: 1.5rem;
198
- border: 3px solid rgba(255,255,255,0.3);
199
- border-radius: 50%;
200
- border-top-color: #fff;
201
- animation: spin 1s ease-in-out infinite;
202
- margin-left: 0.5rem;
203
- vertical-align: middle;
204
- display: none;
205
  }
206
 
207
  .btn-primary.loading .spinner {
208
- display: inline-block;
209
  }
210
 
211
  .btn-primary.loading span {
212
- display: none;
213
  }
214
 
215
  @keyframes spin {
216
- to { transform: rotate(360deg); }
 
 
217
  }
218
 
219
  .hidden {
220
- display: none;
221
  }
222
 
223
  /* API Documentation Styles */
224
  .api-docs {
225
- margin-top: 3rem;
226
- padding-top: 2rem;
227
- border-top: 1px solid var(--border-color);
228
  }
229
 
230
  .api-docs h2 {
231
- font-size: 1.5rem;
232
- margin-bottom: 0.5rem;
233
- color: var(--text-primary);
234
  }
235
 
236
  .api-intro {
237
- color: var(--text-secondary);
238
- margin-bottom: 2rem;
239
- font-size: 0.95rem;
240
  }
241
 
242
  .endpoint {
243
- background: var(--input-bg);
244
- border: 1px solid var(--border-color);
245
- border-radius: 12px;
246
- padding: 1.5rem;
247
- margin-bottom: 1.5rem;
248
  }
249
 
250
  .endpoint-header {
251
- display: flex;
252
- align-items: center;
253
- gap: 1rem;
254
- margin-bottom: 1rem;
255
  }
256
 
257
  .method {
258
- padding: 0.25rem 0.75rem;
259
- border-radius: 4px;
260
- font-weight: 600;
261
- font-size: 0.8rem;
262
- text-transform: uppercase;
263
  }
264
 
265
  .method.get {
266
- background: rgba(46, 160, 67, 0.2);
267
- color: #3fb950;
268
- border: 1px solid rgba(46, 160, 67, 0.4);
269
  }
270
 
271
  .method.post {
272
- background: rgba(88, 166, 255, 0.2);
273
- color: #58a6ff;
274
- border: 1px solid rgba(88, 166, 255, 0.4);
275
  }
276
 
277
  .path {
278
- font-family: 'SF Mono', 'Consolas', monospace;
279
- font-size: 1rem;
280
- color: var(--text-primary);
281
- background: none;
282
- padding: 0;
283
  }
284
 
285
  .endpoint-desc {
286
- color: var(--text-secondary);
287
- margin-bottom: 1rem;
288
- font-size: 0.9rem;
289
  }
290
 
291
  .params-section,
292
  .example-section,
293
  .response-example,
294
  .error-section {
295
- margin-top: 1rem;
296
  }
297
 
298
  .params-section strong,
299
  .example-section strong,
300
  .response-example strong,
301
  .error-section strong {
302
- display: block;
303
- color: var(--text-secondary);
304
- font-size: 0.85rem;
305
- margin-bottom: 0.5rem;
306
  }
307
 
308
  .params-table {
309
- width: 100%;
310
- border-collapse: collapse;
311
- font-size: 0.85rem;
312
- margin-top: 0.5rem;
313
  }
314
 
315
  .params-table th,
316
  .params-table td {
317
- padding: 0.6rem 0.75rem;
318
- text-align: left;
319
- border-bottom: 1px solid var(--border-color);
320
  }
321
 
322
  .params-table th {
323
- color: var(--text-secondary);
324
- font-weight: 500;
325
- background: rgba(0, 0, 0, 0.2);
326
  }
327
 
328
  .params-table td {
329
- color: var(--text-primary);
330
  }
331
 
332
  .params-table code {
333
- background: rgba(88, 166, 255, 0.1);
334
- color: var(--accent-color);
335
- padding: 0.15rem 0.4rem;
336
- border-radius: 4px;
337
- font-size: 0.85em;
338
  }
339
 
340
  .api-docs pre {
341
- background: rgba(0, 0, 0, 0.3);
342
- border: 1px solid var(--border-color);
343
- border-radius: 8px;
344
- padding: 1rem;
345
- overflow-x: auto;
346
- font-family: 'SF Mono', 'Consolas', monospace;
347
- font-size: 0.85rem;
348
- color: var(--text-primary);
349
- line-height: 1.5;
350
  }
351
 
352
  .voices-reference {
353
- margin-top: 2rem;
354
- padding: 1.5rem;
355
- background: var(--input-bg);
356
- border: 1px solid var(--border-color);
357
- border-radius: 12px;
358
  }
359
 
360
  .voices-reference h3 {
361
- font-size: 1.1rem;
362
- margin-bottom: 0.5rem;
363
- color: var(--text-primary);
364
  }
365
 
366
  .voices-reference p {
367
- color: var(--text-secondary);
368
- font-size: 0.9rem;
369
- margin-bottom: 1rem;
370
  }
371
 
372
  .voice-chips {
373
- display: flex;
374
- flex-wrap: wrap;
375
- gap: 0.5rem;
376
- margin-bottom: 1rem;
377
  }
378
 
379
  .voice-chip {
380
- background: rgba(88, 166, 255, 0.15);
381
- color: var(--accent-color);
382
- padding: 0.4rem 0.8rem;
383
- border-radius: 20px;
384
- font-size: 0.85rem;
385
- font-family: 'SF Mono', 'Consolas', monospace;
386
- border: 1px solid rgba(88, 166, 255, 0.3);
387
  }
388
 
389
  .voice-note {
390
- font-size: 0.85rem;
391
- color: var(--text-secondary);
392
- margin-top: 1rem;
393
- margin-bottom: 0;
394
  }
395
 
396
  .voice-note a {
397
- color: var(--accent-color);
398
- text-decoration: none;
399
  }
400
 
401
  .voice-note a:hover {
402
- text-decoration: underline;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  }
404
 
405
  /* Responsive adjustments */
406
  @media (max-width: 600px) {
407
- .params-table {
408
- font-size: 0.75rem;
409
- }
410
-
411
- .params-table th,
412
- .params-table td {
413
- padding: 0.4rem 0.5rem;
414
- }
415
-
416
- .endpoint-header {
417
- flex-wrap: wrap;
418
- }
419
  }
 
1
  :root {
2
+ --bg-color: #0d1117;
3
+ --card-bg: #161b22;
4
+ --accent-color: #58a6ff;
5
+ --accent-hover: #1f6feb;
6
+ --text-primary: #c9d1d9;
7
+ --text-secondary: #8b949e;
8
+ --border-color: #30363d;
9
+ --input-bg: #0d1117;
10
+ --font-family:
11
+ 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial,
12
+ sans-serif;
13
+ --glass-bg: rgba(22, 27, 34, 0.7);
14
+ --glass-border: rgba(48, 54, 61, 0.5);
15
  }
16
 
17
  * {
18
+ box-sizing: border-box;
19
+ margin: 0;
20
+ padding: 0;
21
  }
22
 
23
  body {
24
+ font-family: var(--font-family);
25
+ background-color: var(--bg-color);
26
+ color: var(--text-primary);
27
+ display: flex;
28
+ justify-content: center;
29
+ align-items: center;
30
+ min-height: 100vh;
31
+ background-image: radial-gradient(circle at 50% 0%, #1f2530 0%, #0d1117 100%);
32
  }
33
 
34
  .container {
35
+ width: 100%;
36
+ max-width: 800px;
37
+ padding: 2rem;
38
+ background: var(--glass-bg);
39
+ backdrop-filter: blur(12px);
40
+ border: 1px solid var(--glass-border);
41
+ border-radius: 16px;
42
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
43
+ animation: fadeIn 0.5s ease-out;
44
  }
45
 
46
  @keyframes fadeIn {
47
+ from {
48
+ opacity: 0;
49
+ transform: translateY(20px);
50
+ }
51
+ to {
52
+ opacity: 1;
53
+ transform: translateY(0);
54
+ }
55
  }
56
 
57
  header {
58
+ text-align: center;
59
+ margin-bottom: 2rem;
60
  }
61
 
62
  .logo {
63
+ max-width: 120px;
64
+ margin-bottom: 1rem;
65
+ filter: drop-shadow(0 0 10px rgba(88, 166, 255, 0.3));
66
  }
67
 
68
  h1 {
69
+ font-size: 2rem;
70
+ font-weight: 700;
71
+ color: var(--text-primary);
72
+ margin-bottom: 0.5rem;
73
+ background: linear-gradient(90deg, #58a6ff, #a371f7);
74
+ -webkit-background-clip: text;
75
+ -webkit-text-fill-color: transparent;
76
  }
77
 
78
  .subtitle {
79
+ color: var(--text-secondary);
80
+ font-size: 0.95rem;
81
  }
82
 
83
  .control-group {
84
+ margin-bottom: 1.5rem;
85
+ position: relative;
86
  }
87
 
88
  label {
89
+ display: block;
90
+ margin-bottom: 0.5rem;
91
+ font-weight: 500;
92
+ color: var(--text-secondary);
93
+ font-size: 0.9rem;
94
  }
95
 
96
+ input[type='text'],
97
  textarea,
98
  select {
99
+ width: 100%;
100
+ padding: 0.75rem 1rem;
101
+ background-color: var(--input-bg);
102
+ border: 1px solid var(--border-color);
103
+ border-radius: 8px;
104
+ color: var(--text-primary);
105
+ font-family: inherit;
106
+ font-size: 1rem;
107
+ transition:
108
+ border-color 0.2s,
109
+ box-shadow 0.2s;
110
+ }
111
+
112
+ input[type='file'] {
113
+ width: 100%;
114
+ padding: 0.5rem;
115
+ background-color: var(--input-bg);
116
+ border: 1px dashed var(--border-color);
117
+ border-radius: 8px;
118
+ color: var(--text-secondary);
119
+ cursor: pointer;
120
  }
121
 
122
  input:focus,
123
  textarea:focus,
124
  select:focus {
125
+ outline: none;
126
+ border-color: var(--accent-color);
127
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
128
  }
129
 
130
  textarea {
131
+ min-height: 120px;
132
+ resize: vertical;
133
+ }
134
+
135
+ .input-with-action {
136
+ position: relative;
137
+ display: flex;
138
+ align-items: center;
139
+ }
140
+
141
+ .input-with-action input[type='text'] {
142
+ width: 100%;
143
+ padding-right: 2.5rem;
144
+ }
145
+
146
+ .clear-btn {
147
+ position: absolute;
148
+ right: 0.5rem;
149
+ top: 50%;
150
+ transform: translateY(-50%);
151
+ width: 1.8rem;
152
+ height: 1.8rem;
153
+ padding: 0;
154
+ background: transparent;
155
+ color: var(--text-secondary);
156
+ border: none;
157
+ border-radius: 50%;
158
+ font-size: 0.85rem;
159
+ font-weight: 600;
160
+ line-height: 1;
161
+ cursor: pointer;
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: center;
165
+ transition:
166
+ background-color 0.2s,
167
+ color 0.2s;
168
+ }
169
+
170
+ .clear-btn:hover {
171
+ color: var(--text-primary);
172
+ background-color: rgba(255, 255, 255, 0.1);
173
+ }
174
+
175
+ .clear-btn:disabled {
176
+ cursor: default;
177
+ opacity: 0;
178
+ pointer-events: none;
179
+ }
180
+
181
+ .voice-list {
182
+ position: absolute;
183
+ top: 100%;
184
+ left: 0;
185
+ width: 100%;
186
+ z-index: 100;
187
+ list-style: none;
188
+ margin-top: 4px;
189
+ padding: 0;
190
+ background: var(--card-bg);
191
+ border: 1px solid var(--border-color);
192
+ border-radius: 8px;
193
+ max-height: 250px;
194
+ overflow-y: auto;
195
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
196
+ display: none; /* Changed from just being in flow */
197
+ }
198
+
199
+ .voice-list.show {
200
+ display: block;
201
+ }
202
+
203
+ .voice-list li {
204
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
205
+ }
206
+ .voice-list li:last-child {
207
+ border-bottom: none;
208
+ }
209
+
210
+ .voice-list-item {
211
+ width: 100%;
212
+ text-align: left;
213
+ background: transparent;
214
+ border: none;
215
+ border-radius: 0;
216
+ color: var(--text-primary);
217
+ padding: 0.75rem 1rem;
218
+ font-size: 0.95rem;
219
+ cursor: pointer;
220
+ transition: background-color 0.1s;
221
+ display: grid;
222
+ grid-template-columns: 1fr auto;
223
+ align-items: center;
224
+ gap: 0.5rem;
225
+ }
226
+
227
+ .voice-list-item .voice-info {
228
+ display: flex;
229
+ flex-direction: column;
230
+ gap: 2px;
231
+ overflow: hidden;
232
+ }
233
+
234
+ .voice-list-item .voice-name {
235
+ font-weight: 500;
236
+ white-space: nowrap;
237
+ overflow: hidden;
238
+ text-overflow: ellipsis;
239
+ }
240
+
241
+ .voice-list-item .voice-sub {
242
+ font-size: 0.75rem;
243
+ color: var(--text-secondary);
244
+ font-family: 'SF Mono', 'Consolas', monospace;
245
+ white-space: nowrap;
246
+ overflow: hidden;
247
+ text-overflow: ellipsis;
248
+ opacity: 0.7;
249
+ }
250
+
251
+ .voice-list-item span.voice-badge {
252
+ font-size: 0.7rem;
253
+ font-weight: 600;
254
+ padding: 2px 8px;
255
+ border-radius: 12px;
256
+ text-transform: uppercase;
257
+ letter-spacing: 0.5px;
258
+ white-space: nowrap;
259
+ }
260
+
261
+ .voice-list-item span.voice-badge.badge-builtin {
262
+ color: var(--text-secondary);
263
+ background: rgba(255, 255, 255, 0.1);
264
+ border: 1px solid rgba(255, 255, 255, 0.1);
265
+ }
266
+
267
+ .voice-list-item span.voice-badge.badge-custom {
268
+ color: #7ee787; /* Greenish for custom */
269
+ background: rgba(46, 160, 67, 0.15);
270
+ border: 1px solid rgba(46, 160, 67, 0.2);
271
+ }
272
+
273
+ .voice-list-item:hover,
274
+ .voice-list-item:focus {
275
+ outline: none;
276
+ background: rgba(88, 166, 255, 0.15);
277
+ color: var(--accent-color);
278
+ }
279
+
280
+ .voice-list-empty {
281
+ color: var(--text-secondary);
282
+ padding: 1rem;
283
+ text-align: center;
284
+ font-size: 0.9rem;
285
  }
286
 
287
  .btn-primary {
288
+ display: block;
289
+ width: 100%;
290
+ padding: 1rem;
291
+ border: none;
292
+ border-radius: 8px;
293
+ background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
294
+ color: white;
295
+ font-weight: 600;
296
+ font-size: 1.1rem;
297
+ cursor: pointer;
298
+ transition:
299
+ transform 0.1s,
300
+ opacity 0.2s;
301
+ box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
302
  }
303
 
304
  .btn-primary:hover {
305
+ opacity: 0.9;
306
  }
307
 
308
  .btn-primary:active {
309
+ transform: scale(0.98);
310
  }
311
 
312
  .btn-primary:disabled {
313
+ background: var(--border-color);
314
+ cursor: not-allowed;
315
+ opacity: 0.7;
316
+ box-shadow: none;
317
  }
318
 
319
  .output-section {
320
+ margin-top: 2rem;
321
+ padding-top: 2rem;
322
+ border-top: 1px solid var(--border-color);
323
+ text-align: center;
324
+ display: none; /* Hidden by default */
325
  }
326
 
327
  .output-section.active {
328
+ display: block;
329
+ animation: slideDown 0.3s ease-out;
330
  }
331
 
332
  @keyframes slideDown {
333
+ from {
334
+ opacity: 0;
335
+ transform: translateY(-10px);
336
+ }
337
+ to {
338
+ opacity: 1;
339
+ transform: translateY(0);
340
+ }
341
  }
342
 
343
  audio {
344
+ width: 100%;
345
+ margin-bottom: 1rem;
346
+ border-radius: 8px;
347
  }
348
 
349
  .download-link {
350
+ display: inline-block;
351
+ padding: 0.5rem 1rem;
352
+ color: var(--accent-color);
353
+ text-decoration: none;
354
+ border: 1px solid var(--accent-color);
355
+ border-radius: 6px;
356
+ font-size: 0.9rem;
357
+ transition: all 0.2s;
358
  }
359
 
360
  .download-link:hover {
361
+ background: rgba(88, 166, 255, 0.1);
362
  }
363
 
364
  /* Spinner */
365
  .spinner {
366
+ display: inline-block;
367
+ width: 1.5rem;
368
+ height: 1.5rem;
369
+ border: 3px solid rgba(255, 255, 255, 0.3);
370
+ border-radius: 50%;
371
+ border-top-color: #fff;
372
+ animation: spin 1s ease-in-out infinite;
373
+ margin-left: 0.5rem;
374
+ vertical-align: middle;
375
+ display: none;
376
  }
377
 
378
  .btn-primary.loading .spinner {
379
+ display: inline-block;
380
  }
381
 
382
  .btn-primary.loading span {
383
+ display: none;
384
  }
385
 
386
  @keyframes spin {
387
+ to {
388
+ transform: rotate(360deg);
389
+ }
390
  }
391
 
392
  .hidden {
393
+ display: none;
394
  }
395
 
396
  /* API Documentation Styles */
397
  .api-docs {
398
+ margin-top: 3rem;
399
+ padding-top: 2rem;
400
+ border-top: 1px solid var(--border-color);
401
  }
402
 
403
  .api-docs h2 {
404
+ font-size: 1.5rem;
405
+ margin-bottom: 0.5rem;
406
+ color: var(--text-primary);
407
  }
408
 
409
  .api-intro {
410
+ color: var(--text-secondary);
411
+ margin-bottom: 2rem;
412
+ font-size: 0.95rem;
413
  }
414
 
415
  .endpoint {
416
+ background: var(--input-bg);
417
+ border: 1px solid var(--border-color);
418
+ border-radius: 12px;
419
+ padding: 1.5rem;
420
+ margin-bottom: 1.5rem;
421
  }
422
 
423
  .endpoint-header {
424
+ display: flex;
425
+ align-items: center;
426
+ gap: 1rem;
427
+ margin-bottom: 1rem;
428
  }
429
 
430
  .method {
431
+ padding: 0.25rem 0.75rem;
432
+ border-radius: 4px;
433
+ font-weight: 600;
434
+ font-size: 0.8rem;
435
+ text-transform: uppercase;
436
  }
437
 
438
  .method.get {
439
+ background: rgba(46, 160, 67, 0.2);
440
+ color: #3fb950;
441
+ border: 1px solid rgba(46, 160, 67, 0.4);
442
  }
443
 
444
  .method.post {
445
+ background: rgba(88, 166, 255, 0.2);
446
+ color: #58a6ff;
447
+ border: 1px solid rgba(88, 166, 255, 0.4);
448
  }
449
 
450
  .path {
451
+ font-family: 'SF Mono', 'Consolas', monospace;
452
+ font-size: 1rem;
453
+ color: var(--text-primary);
454
+ background: none;
455
+ padding: 0;
456
  }
457
 
458
  .endpoint-desc {
459
+ color: var(--text-secondary);
460
+ margin-bottom: 1rem;
461
+ font-size: 0.9rem;
462
  }
463
 
464
  .params-section,
465
  .example-section,
466
  .response-example,
467
  .error-section {
468
+ margin-top: 1rem;
469
  }
470
 
471
  .params-section strong,
472
  .example-section strong,
473
  .response-example strong,
474
  .error-section strong {
475
+ display: block;
476
+ color: var(--text-secondary);
477
+ font-size: 0.85rem;
478
+ margin-bottom: 0.5rem;
479
  }
480
 
481
  .params-table {
482
+ width: 100%;
483
+ border-collapse: collapse;
484
+ font-size: 0.85rem;
485
+ margin-top: 0.5rem;
486
  }
487
 
488
  .params-table th,
489
  .params-table td {
490
+ padding: 0.6rem 0.75rem;
491
+ text-align: left;
492
+ border-bottom: 1px solid var(--border-color);
493
  }
494
 
495
  .params-table th {
496
+ color: var(--text-secondary);
497
+ font-weight: 500;
498
+ background: rgba(0, 0, 0, 0.2);
499
  }
500
 
501
  .params-table td {
502
+ color: var(--text-primary);
503
  }
504
 
505
  .params-table code {
506
+ background: rgba(88, 166, 255, 0.1);
507
+ color: var(--accent-color);
508
+ padding: 0.15rem 0.4rem;
509
+ border-radius: 4px;
510
+ font-size: 0.85em;
511
  }
512
 
513
  .api-docs pre {
514
+ background: rgba(0, 0, 0, 0.3);
515
+ border: 1px solid var(--border-color);
516
+ border-radius: 8px;
517
+ padding: 1rem;
518
+ overflow-x: auto;
519
+ font-family: 'SF Mono', 'Consolas', monospace;
520
+ font-size: 0.85rem;
521
+ color: var(--text-primary);
522
+ line-height: 1.5;
523
  }
524
 
525
  .voices-reference {
526
+ margin-top: 2rem;
527
+ padding: 1.5rem;
528
+ background: var(--input-bg);
529
+ border: 1px solid var(--border-color);
530
+ border-radius: 12px;
531
  }
532
 
533
  .voices-reference h3 {
534
+ font-size: 1.1rem;
535
+ margin-bottom: 0.5rem;
536
+ color: var(--text-primary);
537
  }
538
 
539
  .voices-reference p {
540
+ color: var(--text-secondary);
541
+ font-size: 0.9rem;
542
+ margin-bottom: 1rem;
543
  }
544
 
545
  .voice-chips {
546
+ display: flex;
547
+ flex-wrap: wrap;
548
+ gap: 0.5rem;
549
+ margin-bottom: 1rem;
550
  }
551
 
552
  .voice-chip {
553
+ background: rgba(88, 166, 255, 0.15);
554
+ color: var(--accent-color);
555
+ padding: 0.4rem 0.8rem;
556
+ border-radius: 20px;
557
+ font-size: 0.85rem;
558
+ font-family: 'SF Mono', 'Consolas', monospace;
559
+ border: 1px solid rgba(88, 166, 255, 0.3);
560
  }
561
 
562
  .voice-note {
563
+ font-size: 0.85rem;
564
+ color: var(--text-secondary);
565
+ margin-top: 1rem;
566
+ margin-bottom: 0;
567
  }
568
 
569
  .voice-note a {
570
+ color: var(--accent-color);
571
+ text-decoration: none;
572
  }
573
 
574
  .voice-note a:hover {
575
+ text-decoration: underline;
576
+ }
577
+
578
+ /* ID Helper */
579
+ .voice-id-helper {
580
+ margin-top: 0.5rem;
581
+ font-size: 0.8rem;
582
+ color: var(--text-secondary);
583
+ font-family: 'SF Mono', 'Consolas', monospace;
584
+ display: flex;
585
+ align-items: center;
586
+ padding-left: 2px;
587
+ animation: fadeIn 0.3s ease-out;
588
+ }
589
+
590
+ .voice-id-helper span {
591
+ color: var(--accent-color);
592
+ margin-left: 0.5rem;
593
+ background: rgba(88, 166, 255, 0.1);
594
+ padding: 2px 6px;
595
+ border-radius: 4px;
596
+ user-select: all;
597
+ cursor: text;
598
+ }
599
+
600
+ .copy-btn {
601
+ background: transparent;
602
+ border: none;
603
+ color: var(--text-secondary);
604
+ cursor: pointer;
605
+ padding: 4px;
606
+ margin-left: 0.5rem;
607
+ display: flex;
608
+ align-items: center;
609
+ justify-content: center;
610
+ border-radius: 4px;
611
+ transition:
612
+ background-color 0.2s,
613
+ color 0.2s;
614
+ }
615
+
616
+ .copy-btn:hover {
617
+ background: rgba(255, 255, 255, 0.1);
618
+ color: var(--text-primary);
619
+ }
620
+
621
+ .copy-btn:active {
622
+ transform: translateY(1px);
623
  }
624
 
625
  /* Responsive adjustments */
626
  @media (max-width: 600px) {
627
+ .params-table {
628
+ font-size: 0.75rem;
629
+ }
630
+
631
+ .params-table th,
632
+ .params-table td {
633
+ padding: 0.4rem 0.5rem;
634
+ }
635
+
636
+ .endpoint-header {
637
+ flex-wrap: wrap;
638
+ }
639
  }
static/js/app.js CHANGED
@@ -1,5 +1,7 @@
1
  document.addEventListener('DOMContentLoaded', async () => {
2
- const voiceSelect = document.getElementById('voice-select');
 
 
3
  const customVoiceGroup = document.getElementById('custom-voice-group');
4
  const generateBtn = document.getElementById('generate-btn');
5
  const textInput = document.getElementById('text-input');
@@ -8,34 +10,78 @@ document.addEventListener('DOMContentLoaded', async () => {
8
  const audioPlayer = document.getElementById('audio-player');
9
  const downloadBtn = document.getElementById('download-btn');
10
  const streamToggle = document.getElementById('stream-toggle');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  // 1. Load Voices
13
  async function loadVoices() {
14
  try {
15
  const res = await fetch('/v1/voices');
16
  const data = await res.json();
17
-
18
- // Clear existing
19
- voiceSelect.innerHTML = '';
20
 
21
  if (data.data) {
22
- // Add voices in order (built-in first, then custom)
23
  data.data.forEach((voice) => {
24
- const opt = document.createElement('option');
25
- opt.value = voice.id;
26
- opt.textContent = voice.name || voice.id;
27
- voiceSelect.appendChild(opt);
 
 
28
  });
29
 
30
- // Add custom upload option at the end
31
- const customOpt = document.createElement('option');
32
- customOpt.value = 'custom';
33
- customOpt.textContent = 'Custom (Upload .wav, .mp3, .flac)...';
34
- voiceSelect.appendChild(customOpt);
 
 
35
 
36
- // Select first (alba) by default
37
- if (voiceSelect.options.length > 0) {
38
- voiceSelect.selectedIndex = 0;
 
39
  }
40
  }
41
  } catch (e) {
@@ -43,43 +89,351 @@ document.addEventListener('DOMContentLoaded', async () => {
43
  }
44
  }
45
 
46
- await loadVoices();
47
 
48
- // 2. Handle Voice Selection Change (hide custom option in Docker mode)
49
- const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
 
50
 
51
- // Remove the "Custom" option when running in Docker (paths won't work)
52
- if (isDocker) {
53
- const customOption = voiceSelect.querySelector('option[value="custom"]');
54
- if (customOption) customOption.remove();
55
- }
56
 
57
- voiceSelect.addEventListener('change', (e) => {
58
- if (e.target.value === 'custom' && !isDocker) {
59
- // Setup for Custom Path (only for native/exe mode)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  document.querySelector('#custom-voice-group label').textContent =
61
  'Absolute Path to Audio File:';
62
  voiceFile.type = 'text';
63
  voiceFile.placeholder = 'C:\\path\\to\\voice.wav';
64
- customVoiceGroup.classList.remove('hidden');
65
  } else {
66
  customVoiceGroup.classList.add('hidden');
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  });
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  // 3. Generate Logic
71
  generateBtn.addEventListener('click', async () => {
72
  const text = textInput.value.trim();
73
  if (!text) return alert('Please enter text');
74
 
75
- const stream = streamToggle.checked;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- let voice = voiceSelect.value;
78
  if (voice === 'custom') {
79
  voice = voiceFile.value.trim();
80
  if (!voice) return alert('Please enter the path to the voice file.');
81
  }
82
 
 
 
 
 
83
  generateBtn.classList.add('loading');
84
  generateBtn.disabled = true;
85
  outputSection.classList.remove('active');
@@ -92,7 +446,7 @@ document.addEventListener('DOMContentLoaded', async () => {
92
  model: 'pocket-tts',
93
  input: text,
94
  voice: voice,
95
- response_format: 'wav',
96
  stream: stream,
97
  }),
98
  });
@@ -102,13 +456,21 @@ document.addEventListener('DOMContentLoaded', async () => {
102
  throw new Error(err.error || response.statusText);
103
  }
104
 
 
 
 
105
  const blob = await response.blob();
106
  const url = URL.createObjectURL(blob);
107
  audioPlayer.src = url;
108
  downloadBtn.href = url;
109
- downloadBtn.download = 'generated_speech.wav';
110
 
111
- audioPlayer.play();
 
 
 
 
 
112
  outputSection.classList.add('active');
113
  } catch (e) {
114
  alert('Error generating speech: ' + e.message);
@@ -117,4 +479,7 @@ document.addEventListener('DOMContentLoaded', async () => {
117
  generateBtn.disabled = false;
118
  }
119
  });
 
 
 
120
  });
 
1
  document.addEventListener('DOMContentLoaded', async () => {
2
+ const voiceInput = document.getElementById('voice-input');
3
+ const voiceList = document.getElementById('voice-list');
4
+ const voiceClearBtn = document.getElementById('voice-clear-btn');
5
  const customVoiceGroup = document.getElementById('custom-voice-group');
6
  const generateBtn = document.getElementById('generate-btn');
7
  const textInput = document.getElementById('text-input');
 
10
  const audioPlayer = document.getElementById('audio-player');
11
  const downloadBtn = document.getElementById('download-btn');
12
  const streamToggle = document.getElementById('stream-toggle');
13
+ const formatSelect = document.getElementById('format-select');
14
+
15
+ let availableVoices = [];
16
+ let selectedVoiceId = null; // The actual value used for generation
17
+
18
+ // Format & Streaming Logic
19
+ function updateStreamingAvailability() {
20
+ const fmt = formatSelect.value;
21
+ // Server only supports streaming for PCM and WAV currently
22
+ const supportsStreaming = ['wav', 'pcm'].includes(fmt);
23
+ const infoLabel = document.getElementById('format-info');
24
+
25
+ if (supportsStreaming) {
26
+ streamToggle.disabled = false;
27
+ streamToggle.parentElement.title = '';
28
+
29
+ if (fmt === 'pcm') {
30
+ infoLabel.textContent =
31
+ "Streaming is available for Raw PCM. Note: This format creates a specialized raw stream that will not play in the browser's audio player.";
32
+ } else {
33
+ // WAV
34
+ infoLabel.textContent =
35
+ 'Streaming is available for WAV. The server streams audio chunks for lower latency.';
36
+ }
37
+ } else {
38
+ streamToggle.disabled = true;
39
+ streamToggle.checked = false;
40
+ streamToggle.parentElement.title =
41
+ 'Streaming is only available for WAV and PCM formats';
42
+
43
+ if (fmt === 'mp3') {
44
+ infoLabel.textContent =
45
+ 'Streaming is not available for MP3 (Server limitation). A full file will be generated and played.';
46
+ } else {
47
+ infoLabel.textContent = 'Streaming is not available for this format.';
48
+ }
49
+ }
50
+ }
51
+
52
+ formatSelect.addEventListener('change', updateStreamingAvailability);
53
+ // Initialize state
54
+ updateStreamingAvailability();
55
 
56
  // 1. Load Voices
57
  async function loadVoices() {
58
  try {
59
  const res = await fetch('/v1/voices');
60
  const data = await res.json();
61
+ availableVoices = [];
 
 
62
 
63
  if (data.data) {
 
64
  data.data.forEach((voice) => {
65
+ availableVoices.push({
66
+ id: voice.id,
67
+ label: voice.name || voice.id,
68
+ display: voice.name || voice.id, // For search
69
+ type: voice.type || 'builtin',
70
+ });
71
  });
72
 
73
+ // Custom option
74
+ availableVoices.push({
75
+ id: 'custom',
76
+ label: 'Custom Voice',
77
+ display: 'Custom (Upload .wav, .mp3, .flac)...',
78
+ type: 'manual',
79
+ });
80
 
81
+ // Default selection: Prefer first non-custom voice
82
+ const defaultVoice = availableVoices.find((v) => v.id !== 'custom');
83
+ if (defaultVoice) {
84
+ selectVoice(defaultVoice.id, false);
85
  }
86
  }
87
  } catch (e) {
 
89
  }
90
  }
91
 
92
+ // 2. Core Search & Selection Logic
93
 
94
+ function selectVoice(id, closeList = true) {
95
+ const voice = availableVoices.find((v) => v.id === id);
96
+ if (!voice) return;
97
 
98
+ selectedVoiceId = voice.id;
99
+ voiceInput.value = voice.label; // Display nice name
 
 
 
100
 
101
+ // Update ID Display helper
102
+ const idDisplay = document.getElementById('voice-id-display');
103
+ if (idDisplay) {
104
+ if (id !== 'custom') {
105
+ const idSpan = idDisplay.querySelector('.voice-id-text');
106
+ if (idSpan) {
107
+ // Clean extension from ID for cleaner display/copying
108
+ const cleanId = voice.id.replace(
109
+ /\.(wav|mp3|flac|safetensors)$/i,
110
+ '',
111
+ );
112
+ idSpan.textContent = cleanId;
113
+ }
114
+ idDisplay.classList.remove('hidden');
115
+ } else {
116
+ idDisplay.classList.add('hidden');
117
+ }
118
+ }
119
+
120
+ // Handle UI state
121
+ voiceClearBtn.disabled = false;
122
+ if (closeList) hideVoiceList();
123
+
124
+ // Handle Custom
125
+ if (id === 'custom') {
126
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
127
+ if (isDocker) {
128
+ alert('Custom voices are not available in Docker mode.');
129
+ // Fallback to the first non-custom voice if available
130
+ const fallbackVoice = availableVoices.find((v) => v.id !== 'custom');
131
+ if (fallbackVoice) {
132
+ selectVoice(fallbackVoice.id || '');
133
+ } else {
134
+ // No valid fallback; clear selection and hide custom UI
135
+ selectedVoiceId = null;
136
+ voiceInput.value = '';
137
+ voiceClearBtn.disabled = true;
138
+ customVoiceGroup.classList.add('hidden');
139
+ }
140
+ return;
141
+ }
142
+ customVoiceGroup.classList.remove('hidden');
143
  document.querySelector('#custom-voice-group label').textContent =
144
  'Absolute Path to Audio File:';
145
  voiceFile.type = 'text';
146
  voiceFile.placeholder = 'C:\\path\\to\\voice.wav';
 
147
  } else {
148
  customVoiceGroup.classList.add('hidden');
149
  }
150
+ }
151
+
152
+ function renderVoiceList(filterText = '') {
153
+ const normalizedFilter = filterText.trim().toLowerCase();
154
+ const fragment = document.createDocumentFragment();
155
+
156
+ let matchCount = 0;
157
+ let firstMatchId = null;
158
+
159
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
160
+ const filtered = availableVoices.filter((v) => {
161
+ if (v.id === 'custom' && isDocker) return false;
162
+ if (!normalizedFilter) return true;
163
+ return (
164
+ v.id.toLowerCase().includes(normalizedFilter) ||
165
+ v.label.toLowerCase().includes(normalizedFilter) ||
166
+ (v.display && v.display.toLowerCase().includes(normalizedFilter))
167
+ );
168
+ });
169
+
170
+ voiceList.innerHTML = '';
171
+
172
+ if (filtered.length === 0) {
173
+ const emptyItem = document.createElement('li');
174
+ emptyItem.className = 'voice-list-empty';
175
+ emptyItem.textContent = 'No matching voices';
176
+ voiceList.appendChild(emptyItem);
177
+ } else {
178
+ filtered.forEach((voice) => {
179
+ matchCount++;
180
+ if (matchCount === 1) firstMatchId = voice.id;
181
+
182
+ const item = document.createElement('li');
183
+ const btn = document.createElement('button');
184
+ btn.type = 'button';
185
+ btn.className = 'voice-list-item';
186
+ btn.dataset.voiceId = voice.id;
187
+
188
+ // Better content structure
189
+ const infoDiv = document.createElement('div');
190
+ infoDiv.className = 'voice-info';
191
+
192
+ const nameSpan = document.createElement('span');
193
+ nameSpan.className = 'voice-name';
194
+ nameSpan.textContent = voice.display || voice.label;
195
+
196
+ const subSpan = document.createElement('span');
197
+ subSpan.className = 'voice-sub';
198
+ if (voice.id === 'custom') {
199
+ subSpan.textContent = ''; // No ID for the upload button itself
200
+ } else {
201
+ subSpan.textContent = voice.id;
202
+ }
203
+
204
+ infoDiv.appendChild(nameSpan);
205
+ if (subSpan.textContent) infoDiv.appendChild(subSpan);
206
+
207
+ const badgeSpan = document.createElement('span');
208
+ badgeSpan.className = 'voice-badge';
209
+
210
+ // Format badge text: "builtin" -> "Default", "custom" -> "Custom"
211
+ let badgeText = 'Default';
212
+ if (voice.type === 'custom') badgeText = 'Custom';
213
+ if (voice.type === 'manual') badgeText = 'Upload';
214
+
215
+ badgeSpan.textContent = badgeText;
216
+
217
+ // Add specific class for styling if needed
218
+ badgeSpan.classList.add(
219
+ voice.type === 'builtin' ? 'badge-builtin' : 'badge-custom',
220
+ );
221
+
222
+ btn.appendChild(infoDiv);
223
+ btn.appendChild(badgeSpan);
224
+
225
+ item.appendChild(btn);
226
+ fragment.appendChild(item);
227
+ });
228
+ voiceList.appendChild(fragment);
229
+ }
230
+
231
+ return { count: matchCount, firstId: firstMatchId };
232
+ }
233
+
234
+ function showVoiceList() {
235
+ voiceList.classList.add('show');
236
+ renderVoiceList(
237
+ voiceInput.value === getSelectedVoiceLabel() ? '' : voiceInput.value,
238
+ );
239
+ }
240
+
241
+ function hideVoiceList() {
242
+ // Small delay to allow click events to propagate
243
+ setTimeout(() => {
244
+ voiceList.classList.remove('show');
245
+ }, 150);
246
+ }
247
+
248
+ function getSelectedVoiceLabel() {
249
+ const v = availableVoices.find((v) => v.id === selectedVoiceId);
250
+ return v ? v.label : '';
251
+ }
252
+
253
+ // Smart Input Handling
254
+ voiceInput.addEventListener('focus', () => {
255
+ // On focus, if the input value matches the current selection, wipe it to allow fresh search?
256
+ // Or keep it? Standard combobox keeps it but selects text.
257
+ // Let's select text so user can type over immediately.
258
+ voiceInput.select();
259
+ showVoiceList();
260
+ });
261
+
262
+ voiceInput.addEventListener('input', () => {
263
+ voiceClearBtn.disabled = voiceInput.value.length === 0;
264
+ // If user types, we conceptually deselect until they pick or we auto-match
265
+ // But strictly clearing selectedVoiceId might be annoying if they just made a typo.
266
+ // Let's keep selectedVoiceId as fallback, but filter.
267
+ renderVoiceList(voiceInput.value);
268
+ voiceList.classList.add('show');
269
+ });
270
+
271
+ voiceInput.addEventListener('keydown', (e) => {
272
+ if (e.key === 'Escape') {
273
+ voiceInput.value = getSelectedVoiceLabel();
274
+ hideVoiceList();
275
+ voiceInput.blur();
276
+ } else if (e.key === 'Enter') {
277
+ e.preventDefault();
278
+ // Auto-select if 1 result
279
+ const { count, firstId } = renderVoiceList(voiceInput.value);
280
+ if (count === 1 && firstId) {
281
+ selectVoice(firstId);
282
+ voiceInput.blur();
283
+ } else if (count > 0 && firstId) {
284
+ // If multiple, maybe select first? Or do nothing?
285
+ // User asked: "If I filter so much that there is just 1 result, I still have to select it"
286
+ // implies standard Enter behavior triggers selection of top result usually.
287
+ selectVoice(firstId);
288
+ voiceInput.blur();
289
+ }
290
+ }
291
+ });
292
+
293
+ // Handle Blur: Auto-select if logic dictates
294
+ voiceInput.addEventListener('blur', () => {
295
+ // Delay logic slightly to allow Click to happen first
296
+ setTimeout(() => {
297
+ if (!document.activeElement.classList.contains('voice-list-item')) {
298
+ // Validate: Is text a partial match for exactly one voice?
299
+ const val = voiceInput.value.trim();
300
+ if (!val) {
301
+ // Cleared -> maybe clear selection? Or revert?
302
+ // Let's revert to last selected for safety unless user explicitly cleared?
303
+ // If they cleared, they probably want to clear.
304
+ // But we need a voice to generate?
305
+ // Let's revert if empty.
306
+ voiceInput.value = getSelectedVoiceLabel();
307
+ hideVoiceList();
308
+ return;
309
+ }
310
+
311
+ // If the text matches the currently selected label, do nothing
312
+ if (val === getSelectedVoiceLabel()) {
313
+ hideVoiceList();
314
+ return;
315
+ }
316
+
317
+ // Try to find a match
318
+ // 1. Exact Name Match
319
+ const exact = availableVoices.find(
320
+ (v) =>
321
+ v.label.toLowerCase() === val.toLowerCase() ||
322
+ v.id.toLowerCase() === val.toLowerCase(),
323
+ );
324
+ if (exact) {
325
+ selectVoice(exact.id);
326
+ } else {
327
+ // 2. Single Filter Match
328
+ const { count, firstId } = renderVoiceList(val);
329
+ if (count === 1) {
330
+ selectVoice(firstId);
331
+ } else {
332
+ // 3. No clean match (0 or >1). Revert to last valid.
333
+ // User said "get an error because the value from the search field is taken"
334
+ // So passing the raw text is bad. We must force valid selection.
335
+ voiceInput.value = getSelectedVoiceLabel();
336
+ }
337
+ }
338
+ hideVoiceList();
339
+ }
340
+ }, 200);
341
  });
342
 
343
+ // List Click Handling
344
+ voiceList.addEventListener('mousedown', (e) => {
345
+ // Use mousedown to trigger before blur
346
+ const btn = e.target.closest('.voice-list-item');
347
+ if (btn) {
348
+ const id = btn.dataset.voiceId;
349
+ selectVoice(id);
350
+ }
351
+ });
352
+
353
+ voiceClearBtn.addEventListener('mousedown', (e) => {
354
+ e.preventDefault(); // Prevent blur on input
355
+ selectedVoiceId = null;
356
+ voiceInput.value = '';
357
+ voiceInput.focus();
358
+ renderVoiceList('');
359
+ showVoiceList();
360
+ voiceClearBtn.disabled = true;
361
+
362
+ const idDisplay = document.getElementById('voice-id-display');
363
+ if (idDisplay) idDisplay.classList.add('hidden');
364
+ });
365
+
366
+ // Copy Button Logic
367
+ const copyBtn = document.getElementById('voice-id-copy-btn');
368
+ if (copyBtn) {
369
+ copyBtn.addEventListener('click', async () => {
370
+ const idText = document.querySelector('.voice-id-text')?.textContent;
371
+ if (idText) {
372
+ try {
373
+ await navigator.clipboard.writeText(idText);
374
+ const originalHTML = copyBtn.innerHTML;
375
+ // Show checkmark
376
+ copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2ea043" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
377
+ copyBtn.classList.add('copied');
378
+
379
+ setTimeout(() => {
380
+ copyBtn.innerHTML = originalHTML;
381
+ copyBtn.classList.remove('copied');
382
+ }, 1500);
383
+ } catch (err) {
384
+ console.error('Failed to copy: ', err);
385
+ // Fallback for non-secure contexts (optional but good for localhost sometimes)
386
+ const input = document.createElement('textarea');
387
+ input.value = idText;
388
+ document.body.appendChild(input);
389
+ input.select();
390
+ document.execCommand('copy');
391
+ document.body.removeChild(input);
392
+ }
393
+ }
394
+ });
395
+ }
396
+
397
  // 3. Generate Logic
398
  generateBtn.addEventListener('click', async () => {
399
  const text = textInput.value.trim();
400
  if (!text) return alert('Please enter text');
401
 
402
+ // Use the ID, not the Input Value
403
+ let voice = selectedVoiceId;
404
+
405
+ // Fallback: If for some reason ID is null but text exists (shouldn't happen with our blur logic), try to resolve
406
+ if (!voice) {
407
+ // Try to find by name from input
408
+ const val = voiceInput.value.trim();
409
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
410
+ const match = availableVoices.find(
411
+ (v) =>
412
+ (v.label === val || v.id === val) &&
413
+ // In Docker mode, do not allow resolving the special "custom" voice
414
+ !(isDocker && v.id === 'custom'),
415
+ );
416
+ if (match) voice = match.id;
417
+ }
418
+
419
+ if (!voice) return alert('Please choose a valid voice from the list');
420
+
421
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
422
+ if (isDocker && voice === 'custom') {
423
+ return alert(
424
+ 'The custom voice is not available in Docker mode. Please choose another voice.',
425
+ );
426
+ }
427
 
 
428
  if (voice === 'custom') {
429
  voice = voiceFile.value.trim();
430
  if (!voice) return alert('Please enter the path to the voice file.');
431
  }
432
 
433
+ // ... rest of generation logic ...
434
+ const stream = streamToggle.checked;
435
+ const fmt = formatSelect.value;
436
+
437
  generateBtn.classList.add('loading');
438
  generateBtn.disabled = true;
439
  outputSection.classList.remove('active');
 
446
  model: 'pocket-tts',
447
  input: text,
448
  voice: voice,
449
+ response_format: fmt,
450
  stream: stream,
451
  }),
452
  });
 
456
  throw new Error(err.error || response.statusText);
457
  }
458
 
459
+ // Currently we always fetch the full blob and play it once ready.
460
+ // The `stream` flag is still sent to the server, but client playback
461
+ // uses a single blob path for robustness.
462
  const blob = await response.blob();
463
  const url = URL.createObjectURL(blob);
464
  audioPlayer.src = url;
465
  downloadBtn.href = url;
466
+ downloadBtn.download = `generated_speech.${fmt}`;
467
 
468
+ // PCM raw audio usually won't play in standard <audio> elements
469
+ if (fmt !== 'pcm') {
470
+ audioPlayer
471
+ .play()
472
+ .catch((e) => console.warn('Auto-play blocked or failed:', e));
473
+ }
474
  outputSection.classList.add('active');
475
  } catch (e) {
476
  alert('Error generating speech: ' + e.message);
 
479
  generateBtn.disabled = false;
480
  }
481
  });
482
+
483
+ // Initial load
484
+ await loadVoices();
485
  });
templates/index.html CHANGED
@@ -43,13 +43,66 @@ Hello! I am Pocket TTS, running locally on your machine. I can clone voices and
43
  </div>
44
 
45
  <div class="control-group">
46
- <label for="voice-select">Voice Selection</label>
47
- <select id="voice-select">
48
- <!-- Populated by JS -->
49
- <option value="custom">
50
- Custom (Upload .wav, .mp3, or .flac)...
51
- </option>
52
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </div>
54
 
55
  <div class="control-group hidden" id="custom-voice-group">
@@ -59,6 +112,25 @@ Hello! I am Pocket TTS, running locally on your machine. I can clone voices and
59
  <input type="file" id="voice-file" accept=".wav,.mp3,.flac" />
60
  </div>
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  <div class="control-group">
63
  <label
64
  for="stream-toggle"
@@ -72,6 +144,15 @@ Hello! I am Pocket TTS, running locally on your machine. I can clone voices and
72
  />
73
  Enable Streaming Response
74
  </label>
 
 
 
 
 
 
 
 
 
75
  </div>
76
 
77
  <button id="generate-btn" class="btn-primary">
 
43
  </div>
44
 
45
  <div class="control-group">
46
+ <label for="voice-input">Voice Selection</label>
47
+ <div class="input-with-action">
48
+ <input
49
+ id="voice-input"
50
+ type="text"
51
+ autocomplete="off"
52
+ placeholder="Select a voice..."
53
+ />
54
+ <button
55
+ type="button"
56
+ class="clear-btn"
57
+ id="voice-clear-btn"
58
+ aria-label="Clear voice search"
59
+ >
60
+ <svg
61
+ width="14"
62
+ height="14"
63
+ viewBox="0 0 24 24"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ stroke-width="2"
67
+ stroke-linecap="round"
68
+ stroke-linejoin="round"
69
+ >
70
+ <line x1="18" y1="6" x2="6" y2="18"></line>
71
+ <line x1="6" y1="6" x2="18" y2="18"></line>
72
+ </svg>
73
+ </button>
74
+ <ul
75
+ id="voice-list"
76
+ class="voice-list"
77
+ aria-label="Available voices"
78
+ >
79
+ <!-- Populated by JS -->
80
+ </ul>
81
+ </div>
82
+ <div id="voice-id-display" class="voice-id-helper hidden">
83
+ ID: <span class="voice-id-text"></span>
84
+ <button
85
+ class="copy-btn"
86
+ id="voice-id-copy-btn"
87
+ title="Copy to clipboard"
88
+ >
89
+ <svg
90
+ width="12"
91
+ height="12"
92
+ viewBox="0 0 24 24"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ stroke-width="2"
96
+ stroke-linecap="round"
97
+ stroke-linejoin="round"
98
+ >
99
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
100
+ <path
101
+ d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
102
+ ></path>
103
+ </svg>
104
+ </button>
105
+ </div>
106
  </div>
107
 
108
  <div class="control-group hidden" id="custom-voice-group">
 
112
  <input type="file" id="voice-file" accept=".wav,.mp3,.flac" />
113
  </div>
114
 
115
+ <div class="control-group">
116
+ <label for="format-select">Output Format</label>
117
+ <select
118
+ id="format-select"
119
+ style="
120
+ width: 100%;
121
+ padding: 8px;
122
+ border-radius: 4px;
123
+ border: 1px solid var(--border-color);
124
+ background: var(--input-bg);
125
+ color: var(--text-color);
126
+ "
127
+ >
128
+ <option value="mp3">MP3</option>
129
+ <option value="wav" selected>WAV</option>
130
+ <option value="pcm">PCM (Raw)</option>
131
+ </select>
132
+ </div>
133
+
134
  <div class="control-group">
135
  <label
136
  for="stream-toggle"
 
144
  />
145
  Enable Streaming Response
146
  </label>
147
+ <p
148
+ id="format-info"
149
+ style="
150
+ font-size: 0.85em;
151
+ color: var(--text-secondary);
152
+ margin-top: 8px;
153
+ line-height: 1.4;
154
+ "
155
+ ></p>
156
  </div>
157
 
158
  <button id="generate-btn" class="btn-primary">