mistpe commited on
Commit
3f21651
·
verified ·
1 Parent(s): e708129

Update main.ts

Browse files
Files changed (1) hide show
  1. main.ts +112 -779
main.ts CHANGED
@@ -1,802 +1,135 @@
1
- // import { serve } from "https://deno.land/std/http/server.ts";
2
- // import { EdgeSpeechTTS } from "https://esm.sh/@lobehub/tts@1";
3
-
4
- // const AUTH_TOKEN = Deno.env.get("AUTH_TOKEN");
5
- // const VOICES_URL = "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4";
6
-
7
- // async function fetchVoiceList() {
8
- // const response = await fetch(VOICES_URL);
9
- // const voices = await response.json();
10
- // return voices.reduce((acc: Record<string, { model: string, name: string, friendlyName: string, locale: string }[]>, voice: any) => {
11
- // const { ShortName: model, ShortName: name, FriendlyName: friendlyName, Locale: locale } = voice;
12
- // if (!acc[locale]) acc[locale] = [];
13
- // acc[locale].push({ model, name, friendlyName, locale });
14
- // return acc;
15
- // }, {});
16
- // }
17
-
18
- // async function synthesizeSpeech(model: string, voice: string, text: string) {
19
- // let voiceName;
20
- // let rate = 0;
21
- // let pitch = 0;
22
-
23
- // if (model.includes("tts")) {
24
- // rate = 0.1;
25
- // pitch = 0.2;
26
 
27
- // switch (voice) {
28
- // case "alloy":
29
- // voiceName = "zh-CN-YunjianNeural";
30
- // break;
31
- // case "echo":
32
- // voiceName = "zh-CN-YunyangNeural";
33
- // break;
34
- // case "fable":
35
- // voiceName = "zh-CN-XiaoxiaoNeural";
36
- // break;
37
- // case "onyx":
38
- // voiceName = "zh-TW-HsiaoChenNeural";
39
- // break;
40
- // default:
41
- // voiceName = "zh-CN-YunxiNeural";
42
- // break;
43
- // }
44
- // } else {
45
- // voiceName = model;
46
- // const params = Object.fromEntries(
47
- // voice.split("|").map((p) => p.split(":") as [string, string])
48
- // );
49
- // rate = Number(params["rate"] || 0);
50
- // pitch = Number(params["pitch"] || 0);
51
- // }
 
 
 
 
 
 
 
 
 
52
 
53
- // const tts = new EdgeSpeechTTS();
54
 
55
- // const payload = {
56
- // input: text,
57
- // options: {
58
- // rate: rate,
59
- // pitch: pitch,
60
- // voice: voiceName
61
- // },
62
- // };
63
- // const response = await tts.create(payload);
64
- // const mp3Buffer = new Uint8Array(await response.arrayBuffer());
65
-
66
- // console.log(`Successfully synthesized speech, returning audio/mpeg response`);
67
- // return new Response(mp3Buffer, {
68
- // headers: { "Content-Type": "audio/mpeg" },
69
- // });
70
- // }
71
 
72
- // function unauthorized(req: Request) {
73
- // const authHeader = req.headers.get("Authorization");
74
- // return AUTH_TOKEN && authHeader !== `Bearer ${AUTH_TOKEN}`;
75
- // }
76
 
77
- // function validateContentType(req: Request, expected: string) {
78
- // const contentType = req.headers.get("Content-Type");
79
- // if (contentType !== expected) {
80
- // console.log(`Invalid Content-Type ${contentType}, expected ${expected}`);
81
- // return new Response("Bad Request", { status: 400 });
82
- // }
83
- // }
84
 
85
- // async function handleDebugRequest(req: Request) {
86
- // const url = new URL(req.url);
87
- // const voice = url.searchParams.get("voice") || "";
88
- // const model = url.searchParams.get("model") || "";
89
- // const text = url.searchParams.get("text") || "";
90
 
91
- // console.log(`Debug request with model=${model}, voice=${voice}, text=${text}`);
92
 
93
- // if (!voice || !model || !text) {
94
- // console.log("Missing required parameters");
95
- // return new Response("Bad Request", { status: 400 });
96
- // }
97
 
98
- // return synthesizeSpeech(model, voice, text);
99
- // }
100
 
101
- // async function handleSynthesisRequest(req: Request) {
102
- // if (unauthorized(req)) {
103
- // console.log("Unauthorized request");
104
- // return new Response("Unauthorized", { status: 401 });
105
- // }
106
 
107
- // if (req.method !== "POST") {
108
- // console.log(`Invalid method ${req.method}, expected POST`);
109
- // return new Response("Method Not Allowed", { status: 405 });
110
- // }
111
 
112
- // const invalidContentType = validateContentType(req, "application/json");
113
- // if (invalidContentType) return invalidContentType;
114
 
115
- // const { model, input, voice } = await req.json();
116
- // console.log(`Synthesis request with model=${model}, input=${input}, voice=${voice}`);
117
 
118
- // return synthesizeSpeech(model, voice, input);
119
- // }
120
 
121
 
122
- // async function handleDemoRequest(req: Request) {
123
- // const groupedVoiceList = await fetchVoiceList();
124
 
125
- // const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>语音合成演示</title><link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet"><style>:root{--primary-color:#6c8bd6;--primary-light:#a2b3e3;--primary-dark:#3d5b8f;--secondary-color:#f08080;--text-color:#333;--text-secondary:#777;--bg-color:#fff}body{font-family:'Noto Sans SC','Arial',sans-serif;color:var(--text-color);margin:0;padding:0;display:flex;justify-content:center;background-color:#fafafa;background-image:linear-gradient(135deg,#f5f7fa 0%,#c3cfe2 100%);position:relative;overflow:hidden}body::before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:repeating-radial-gradient(circle at 50% 50%,rgba(255,255,255,0.8) 0%,rgba(255,255,255,0.8) 2%,transparent 2%,transparent 4%,rgba(255,255,255,0.8) 4%,rgba(255,255,255,0.8) 6%,transparent 6%,transparent 8%,rgba(255,255,255,0.8) 8%,rgba(255,255,255,0.8) 10%,transparent 10%),repeating-linear-gradient(45deg,#D4F4FF 0%,#D4F4FF 5%,#E6F9FF 5%,#E6F9FF 10%,#F0FAFF 10%,#F0FAFF 15%,#E6F9FF 15%,#E6F9FF 20%,#D4F4FF 20%,#D4F4FF 25%);background-blend-mode:multiply;opacity:0.8;z-index:-1;animation:glitch 15s infinite}.container{display:flex;max-width:1200px;width:100%;margin:40px;background:#fff;border-radius:12px;position:relative;background-color:rgba(255,255,255,0.8);z-index:1}@keyframes glitch{0%{background-position:0 0,0 0;filter:hue-rotate(0deg)}50%{background-position:10px 10px,-10px 10px;filter:hue-rotate(360deg)}100%{background-position:0 0,0 0;filter:hue-rotate(0deg)}}.input-area,.output-area{padding:30px;width:50%}.input-area{border-right:1px solid #E0E0E0}h1{font-size:36px;color:var(--primary-color);margin-bottom:30px}.filter-section{margin-bottom:30px}.filter-section label{display:block;font-size:16px;color:var(--text-secondary);margin-bottom:10px}.filter-section input{font-size:16px;padding:10px 15px;border:2px solid var(--primary-light);border-radius:8px;outline:none;transition:border-color .3s,box-shadow .3s;width:100%;box-sizing:border-box}.filter-section input:focus{border-color:var(--primary-color);box-shadow:0 0 0 2px var(--primary-light)}.slider-container{margin-bottom:30px}.slider-container label{display:block;font-size:16px;color:var(--text-secondary);margin-bottom:10px}.slider{-webkit-appearance:none;width:100%;height:10px;border-radius:5px;background:linear-gradient(to right,var(--secondary-color) 0%,var(--primary-color) 50%,var(--primary-light) 100%);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1),0 1px rgba(255,255,255,0.1);outline:none;opacity:0.7;-webkit-transition:.2s;transition:opacity .2s;margin-bottom:10px}.slider:hover{opacity:1}.slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;border-radius:50%;background:#fff;border:2px solid var(--primary-color);cursor:pointer}.slider::-moz-range-thumb{width:20px;height:20px;border-radius:50%;background:#fff;border:2px solid var(--primary-color);cursor:pointer}.slider-value{font-size:14px;color:var(--text-secondary)}.textarea-container{margin-bottom:30px}.textarea-container label{display:block;font-size:18px;margin-bottom:10px}.textarea-container textarea{width:100%;padding:10px;font-size:16px;border:2px solid var(--primary-light);border-radius:8px;outline:none;resize:vertical;transition:border-color .3s,box-shadow .3s;box-sizing:border-box;height:200px}.textarea-container textarea:focus{border-color:var(--primary-color);box-shadow:0 0 0 2px var(--primary-light)}.voice-group{margin-bottom:20px;border:2px solid var(--primary-light);border-radius:12px;overflow:hidden;cursor:move;background:#fff}.voice-header{padding:15px 20px;font-size:18px;background:var(--primary-light);color:#fff;cursor:pointer;display:flex;justify-content:space-between;align-items:center}.voice-header:hover{background:var(--primary-color)}.voice-buttons{padding:20px;display:none;gap:12px;flex-wrap:wrap}.voice-button{background:var(--secondary-color);color:#fff;border:none;padding:10px 20px;border-radius:50px;cursor:pointer;transition:filter .3s}.voice-button:hover{filter:brightness(0.9)}.chevron{transition:transform .3s}.voice-group.open .voice-buttons{display:flex}.voice-group.open .chevron{transform:rotate(180deg)}.dragging{opacity:0.5}</style></head><body><div class="container"><div class="input-area"><h1>输入文本</h1><div class="filter-section"><label for="keywords">Speaker筛选:</label><input type="text" id="keywords" value="multilingual,-TW,-CN"></div><div class="slider-container"><label for="rate">语速:</label><input type="range" min="-1" max="1" step="0.1" value="-0.1" class="slider" id="rate"><div class="slider-value" id="rateValue">-0.1</div><label for="pitch">音调:</label><input type="range" min="-1" max="1" step="0.1" value="0.1" class="slider" id="pitch"><div class="slider-value" id="pitchValue">0.1</div></div><div class="textarea-container"><label for="inputText">输入文本:</label><textarea id="inputText">Hello world</textarea></div></div><div class="output-area"><h1>选择语音</h1><div id="voices"></div></div></div><script>const voiceList = ${JSON.stringify(groupedVoiceList)};let audio=null;function filterVoices(){const keywords=document.getElementById('keywords').value.split(',').map(k=>k.trim().toLowerCase());const voicesDiv=document.getElementById('voices');voicesDiv.innerHTML='';const filteredVoices={};for(const[locale,voices]of Object.entries(voiceList)){const filtered=voices.filter(({name,friendlyName})=>keywords.some(keyword=>name.toLowerCase().includes(keyword)||friendlyName.toLowerCase().includes(keyword)));if(filtered.length>0){filteredVoices[locale]=filtered}}for(const[locale,voices]of Object.entries(filteredVoices)){const group=document.createElement('div');group.className='voice-group';group.draggable=true;const header=document.createElement('div');header.className='voice-header';header.textContent=locale.toUpperCase();const chevron=document.createElement('span');chevron.className='chevron';chevron.innerHTML='&#9660;';header.appendChild(chevron);const buttonsContainer=document.createElement('div');buttonsContainer.className='voice-buttons';voices.forEach(({model,name})=>{const button=document.createElement('button');button.className='voice-button';button.textContent=name;button.onclick=()=>synthesize(model);buttonsContainer.appendChild(button)});header.onclick=()=>{group.classList.toggle('open')};group.appendChild(header);group.appendChild(buttonsContainer);voicesDiv.appendChild(group)}addDragDropListeners()}function synthesize(model){const text=document.getElementById('inputText').value||'Hello world';const rate=document.getElementById('rate').value||'-0.1';const pitch=document.getElementById('pitch').value||'0.1';const voice=\`rate:\${rate}|pitch:\${pitch}\`;if(audio){audio.pause();audio.currentTime=0}fetch('/v1/audio/speech',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model,input:text,voice})}).then(response=>response.blob()).then(blob=>{const audioUrl=URL.createObjectURL(blob);audio=new Audio(audioUrl);audio.play()})}function addDragDropListeners(){const voicesDiv=document.getElementById('voices');let draggedItem=null;voicesDiv.addEventListener('dragstart',e=>{draggedItem=e.target;e.target.classList.add('dragging')});voicesDiv.addEventListener('dragend',e=>{e.target.classList.remove('dragging');draggedItem=null});voicesDiv.addEventListener('dragover',e=>{e.preventDefault();const afterElement=getDragAfterElement(voicesDiv,e.clientY);if(afterElement==null){voicesDiv.appendChild(draggedItem)}else{voicesDiv.insertBefore(draggedItem,afterElement)}})}function getDragAfterElement(container,y){const draggableElements=[...container.querySelectorAll('.voice-group:not(.dragging)')];return draggableElements.reduce((closest,child)=>{const box=child.getBoundingClientRect();const offset=y-box.top-box.height/2;if(offset<0&&offset>closest.offset){return{offset:offset,element:child}}else{return closest}},{offset:Number.NEGATIVE_INFINITY}).element}filterVoices();document.getElementById('keywords').addEventListener('input',filterVoices);const rateSlider=document.getElementById('rate');const rateValue=document.getElementById('rateValue');rateSlider.oninput=function(){rateValue.innerHTML=this.value};const pitchSlider=document.getElementById('pitch');const pitchValue=document.getElementById('pitchValue');pitchSlider.oninput=function(){pitchValue.innerHTML=this.value}</script></body></html>`;
126
 
127
- // return new Response(html, {
128
- // headers: { "Content-Type": "text/html" },
129
- // });
130
- // }
131
 
132
 
133
- // serve(async (req) => {
134
- // try {
135
- // const url = new URL(req.url);
136
-
137
- // if (url.pathname === "/") {
138
- // return handleDemoRequest(req);
139
- // }
140
-
141
- // if (url.pathname === "/tts") {
142
- // return handleDebugRequest(req);
143
- // }
144
-
145
- // if (url.pathname !== "/v1/audio/speech") {
146
- // console.log(`Unhandled path ${url.pathname}`);
147
- // return new Response("Not Found", { status: 404 });
148
- // }
149
-
150
- // return handleSynthesisRequest(req);
151
- // } catch (err) {
152
- // console.error(`Error processing request: ${err.message}`);
153
- // return new Response(`Internal Server Error\n${err.message}`, {
154
- // status: 500,
155
- // });
156
- // }
157
- // });
158
-
159
- display: grid;
160
- grid-template-columns: 1fr;
161
- gap: 20px;
162
- background: var(--bg-color);
163
- border-radius: var(--border-radius);
164
- box-shadow: var(--shadow);
165
- overflow: hidden;
166
- }
167
-
168
- @media (min-width: 768px) {
169
- .main-content {
170
- grid-template-columns: 1fr 1fr;
171
- }
172
- }
173
-
174
- .panel {
175
- padding: 24px;
176
- }
177
-
178
- .panel-title {
179
- font-size: 1.5rem;
180
- font-weight: 600;
181
- color: var(--primary-color);
182
- margin-bottom: 20px;
183
- display: flex;
184
- align-items: center;
185
- gap: 8px;
186
- }
187
-
188
- .input-panel {
189
- border-right: none;
190
- }
191
-
192
- @media (min-width: 768px) {
193
- .input-panel {
194
- border-right: 1px solid var(--border-color);
195
- }
196
- }
197
-
198
- .form-group {
199
- margin-bottom: 20px;
200
- }
201
-
202
- .form-label {
203
- display: block;
204
- font-weight: 500;
205
- color: var(--text-color);
206
- margin-bottom: 8px;
207
- font-size: 0.9rem;
208
- }
209
-
210
- .form-input {
211
- width: 100%;
212
- padding: 12px 16px;
213
- border: 2px solid var(--border-color);
214
- border-radius: var(--border-radius);
215
- font-size: 14px;
216
- transition: all 0.3s ease;
217
- background: var(--bg-color);
218
- }
219
-
220
- .form-input:focus {
221
- outline: none;
222
- border-color: var(--primary-color);
223
- box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1);
224
- }
225
-
226
- .form-textarea {
227
- min-height: 120px;
228
- resize: vertical;
229
- font-family: inherit;
230
- }
231
-
232
- .slider-container {
233
- margin-bottom: 20px;
234
- }
235
-
236
- .
237
- }
238
-
239
- .slide
240
-
241
- .voice-group:last-child {
242
- border-bottom: none;
243
-
244
- flex-wrap: wrap;
245
- background: var(--bg-secondary);
246
- }
247
-
248
- .voice-group.open .voice-buttons {
249
- display: flex;
250
- }
251
-
252
- .voice-button {
253
- background: white;
254
- color: var(--text-color);
255
- border: 1px solid var(--border-color);
256
- padding: 8px 16px;
257
- border-radius: 20px;
258
- cursor: pointer;
259
- transition: all 0.3s ease;
260
- font-size: 0.85rem;
261
- font-weight: 400;
262
- white-space: nowrap;
263
- position: relative;
264
- overflow: hidden;
265
- }
266
-
267
- .voice-button:hover {
268
- border-color: var(--primary-color);
269
- color: var(--primary-color);
270
- transform: translateY(-1px);
271
- box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
272
- }
273
-
274
- .voice-button:active {
275
- transform: translateY(0);
276
- }
277
-
278
- .voice-button.playing {
279
- background: var(--secondary-color);
280
- color: white;
281
- border-color: var(--secondary-color);
282
- animation: pulse 1.5s infinite;
283
- }
284
-
285
- @keyframes pulse {
286
- 0% { transform: scale(1); }
287
- 50% { transform: scale(1.05); }
288
- 100% { transform: scale(1); }
289
- }
290
-
291
- .chinese-voices {
292
- background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
293
- }
294
-
295
- .chinese-voices .voice-header {
296
- background: rgba(255, 255, 255, 0.9);
297
- }
298
-
299
- .chinese-voices .voice-header:hover {
300
- background: rgba(255, 255, 255, 1);
301
- }
302
-
303
- .chinese-voices .voice-header.active {
304
- background: var(--accent-color);
305
- }
306
-
307
- .loading {
308
- display: inline-block;
309
- width: 16px;
310
- height: 16px;
311
- border: 2px solid var(--border-color);
312
- border-radius: 50%;
313
- border-top-color: var(--primary-color);
314
- animation: spin 1s linear infinite;
315
- margin-right: 8px;
316
- }
317
-
318
- @keyframes spin {
319
- to { transform: rotate(360deg); }
320
- }
321
-
322
- .status-message {
323
- margin-top: 16px;
324
- padding: 12px 16px;
325
- border-radius: var(--border-radius);
326
- font-size: 0.9rem;
327
- text-align: center;
328
- display: none;
329
- }
330
-
331
- .status-message.show {
332
- display: block;
333
- }
334
-
335
- .status-message.success {
336
- background: #f6ffed;
337
- color: #52c41a;
338
- border: 1px solid #b7eb8f;
339
- }
340
-
341
- .status-message.error {
342
- background: #fff2f0;
343
- color: #ff4d4f;
344
- border: 1px solid #ffccc7;
345
- }
346
-
347
- .mobile-controls {
348
- position: fixed;
349
- bottom: 0;
350
- left: 0;
351
- right: 0;
352
- background: white;
353
- padding: 16px 20px;
354
- box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
355
- border-top: 1px solid var(--border-color);
356
- display: none;
357
- z-index: 1000;
358
- }
359
-
360
- @media (max-width: 767px) {
361
- .mobile-controls {
362
- display: block;
363
- }
364
-
365
- .voice-container {
366
- margin-bottom: 80px;
367
- }
368
- }
369
-
370
- .current-audio-info {
371
- font-size: 0.8rem;
372
- color: var(--text-secondary);
373
- margin-bottom: 8px;
374
- }
375
-
376
- .audio-controls {
377
- display: flex;
378
- gap: 12px;
379
- align-items: center;
380
- }
381
-
382
- .control-button {
383
- background: var(--primary-color);
384
- color: white;
385
- border: none;
386
- padding: 10px 16px;
387
- border-radius: var(--border-radius);
388
- cursor: pointer;
389
- font-size: 0.9rem;
390
- transition: all 0.3s ease;
391
- flex: 1;
392
- }
393
-
394
- .control-button:hover {
395
- background: var(--primary-dark);
396
- transform: translateY(-1px);
397
- }
398
-
399
- .control-button:disabled {
400
- background: var(--border-color);
401
- color: var(--text-secondary);
402
- cursor: not-allowed;
403
- transform: none;
404
- }
405
-
406
- @media (max-width: 480px) {
407
- .container {
408
- padding: 10px;
409
- }
410
-
411
- .panel {
412
- padding: 16px;
413
- }
414
-
415
- .voice-button {
416
- padding: 6px 12px;
417
- font-size: 0.8rem;
418
- }
419
- }
420
-
421
- .filter-section {
422
- margin-bottom: 20px;
423
- padding: 16px;
424
- background: var(--bg-secondary);
425
- border-radius: var(--border-radius);
426
- border: 1px solid var(--border-color);
427
- }
428
-
429
- .filter-tabs {
430
- display: flex;
431
- gap: 8px;
432
- margin-bottom: 12px;
433
- flex-wrap: wrap;
434
- }
435
-
436
- .filter-tab {
437
- padding: 6px 12px;
438
- border: 1px solid var(--border-color);
439
- border-radius: 16px;
440
- background: white;
441
- cursor: pointer;
442
- font-size: 0.8rem;
443
- transition: all 0.3s ease;
444
- }
445
-
446
- .filter-tab.active {
447
- background: var(--primary-color);
448
- color: white;
449
- border-color: var(--primary-color);
450
- }
451
-
452
- .filter-tab:hover:not(.active) {
453
- border-color: var(--primary-light);
454
- color: var(--primary-color);
455
- }
456
- </style>
457
- </head>
458
-
459
- <body>
460
- <div class="container">
461
- <div class="header">
462
- <h1>🎵 Edge TTS 语音合成</h1>
463
- <p>支持多语言高质量语音合成,特别优化中文语音体验</p>
464
- </div>
465
-
466
- <div class="main-content">
467
- <div class="panel input-panel">
468
- <h2 class="panel-title">
469
- 📝 输入设置
470
- </h2>
471
-
472
- <div class="filter-section">
473
- <div class="filter-tabs">
474
- <div class="filter-tab active" data-filter="chinese">中文语音</div>
475
- <div class="filter-tab" data-filter="english">英文语音</div>
476
- <div class="filter-tab" data-filter="multilingual">多语言</div>
477
- <div class="filter-tab" data-filter="all">全部语音</div>
478
- </div>
479
- <input type="text" id="customFilter" class="form-input" placeholder="自定义筛选关键词...">
480
- </div>
481
-
482
- <div class="form-group">
483
- <label class="form-label" for="rate">语速调节</label>
484
- <div class="slider-wrapper">
485
- <input type="range" min="-1" max="1" step="0.1" value="-0.1" class="slider" id="rate">
486
- </div>
487
- <div class="slider-value" id="rateValue">-0.1</div>
488
- </div>
489
-
490
- <div class="form-group">
491
- <label class="form-label" for="pitch">音调调节</label>
492
- <div class="slider-wrapper">
493
- <input type="range" min="-1" max="1" step="0.1" value="0.1" class="slider" id="pitch">
494
- </div>
495
- <div class="slider-value" id="pitchValue">0.1</div>
496
- </div>
497
-
498
- <div class="form-group">
499
- <label class="form-label" for="inputText">输入文本</label>
500
- <textarea id="inputText" class="form-input form-textarea" placeholder="请输入要转换为语音的文本内容...">你好,欢迎使用Edge TTS语音合成服务!这里支持多种中文语音选择。</textarea>
501
- </div>
502
-
503
- <div class="status-message" id="statusMessage"></div>
504
- </div>
505
-
506
- <div class="panel">
507
- <h2 class="panel-title">
508
- 🎤 语音选择
509
- </h2>
510
-
511
- <div class="voice-container" id="voices"></div>
512
- </div>
513
- </div>
514
-
515
- <div class="mobile-controls">
516
- <div class="current-audio-info" id="currentAudioInfo">选择语音后开始合成</div>
517
- <div class="audio-controls">
518
- <button class="control-button" id="pauseBtn" disabled>暂停</button>
519
- <button class="control-button" id="stopBtn" disabled>停止</button>
520
- </div>
521
- </div>
522
- </div>
523
-
524
- <script>
525
- const voiceList = ${JSON.stringify(groupedVoiceList)};
526
- let audio = null;
527
- let currentVoiceButton = null;
528
- let currentFilter = 'chinese';
529
-
530
- // 中文语音配置
531
- const chineseVoiceMapping = {
532
- 'zh-CN': '中文 (普通话)',
533
- 'zh-HK': '中文 (粤语)',
534
- 'zh-TW': '中文 (台湾话)',
535
- 'zh-CN-liaoning': '中文 (辽宁话)',
536
- 'zh-CN-shaanxi': '中文 (陕西话)'
537
- };
538
-
539
- const filterPresets = {
540
- chinese: ['zh-CN', 'zh-HK', 'zh-TW', 'zh-CN-liaoning', 'zh-CN-shaanxi'],
541
- english: ['en-US', 'en-GB', 'en-AU', 'en-CA', 'en-IN'],
542
- multilingual: Object.keys(voiceList).filter(locale =>
543
- !locale.startsWith('zh-') && !locale.startsWith('en-')
544
- ).slice(0, 10)
545
- };
546
-
547
- function showStatusMessage(message, type = 'success') {
548
- const statusEl = document.getElementById('statusMessage');
549
- statusEl.textContent = message;
550
- statusEl.className = \`status-message show \${type}\`;
551
- setTimeout(() => {
552
- statusEl.classList.remove('show');
553
- }, 3000);
554
- }
555
-
556
- function updateMobileControls(voiceName = '') {
557
- const infoEl = document.getElementById('currentAudioInfo');
558
- const pauseBtn = document.getElementById('pauseBtn');
559
- const stopBtn = document.getElementById('stopBtn');
560
-
561
- if (voiceName) {
562
- infoEl.textContent = \`当前语音: \${voiceName}\`;
563
- pauseBtn.disabled = false;
564
- stopBtn.disabled = false;
565
- } else {
566
- infoEl.textContent = '选择语音后开始合成';
567
- pauseBtn.disabled = true;
568
- stopBtn.disabled = true;
569
- }
570
- }
571
-
572
- function filterVoices(filterType = 'chinese', customKeyword = '') {
573
- const voicesDiv = document.getElementById('voices');
574
- voicesDiv.innerHTML = '';
575
-
576
- let filteredVoices = {};
577
-
578
- if (filterType === 'all') {
579
- filteredVoices = voiceList;
580
- } else if (filterPresets[filterType]) {
581
- for (const locale of filterPresets[filterType]) {
582
- if (voiceList[locale]) {
583
- filteredVoices[locale] = voiceList[locale];
584
- }
585
- }
586
- }
587
-
588
- // 应用自定义关键词过滤
589
- if (customKeyword.trim()) {
590
- const keyword = customKeyword.trim().toLowerCase();
591
- const tempFiltered = {};
592
-
593
- for (const [locale, voices] of Object.entries(filteredVoices)) {
594
- const matchingVoices = voices.filter(voice =>
595
- voice.name.toLowerCase().includes(keyword) ||
596
- voice.friendlyName.toLowerCase().includes(keyword) ||
597
- locale.toLowerCase().includes(keyword)
598
- );
599
-
600
- if (matchingVoices.length > 0) {
601
- tempFiltered[locale] = matchingVoices;
602
- }
603
- }
604
-
605
- filteredVoices = tempFiltered;
606
- }
607
-
608
- // 渲染语音组
609
- for (const [locale, voices] of Object.entries(filteredVoices)) {
610
- const group = document.createElement('div');
611
- group.className = \`voice-group \${filterType === 'chinese' ? 'chinese-voices' : ''}\`;
612
-
613
- const header = document.createElement('div');
614
- header.className = 'voice-header';
615
-
616
- const displayName = chineseVoiceMapping[locale] || locale.toUpperCase();
617
- const headerTitle = document.createElement('div');
618
- headerTitle.innerHTML = \`
619
- <span class="voice-header-title">\${displayName}</span>
620
- <span class="voice-header-count">\${voices.length}个</span>
621
- \`;
622
-
623
- const chevron = document.createElement('span');
624
- chevron.className = 'chevron';
625
- chevron.innerHTML = '▼';
626
-
627
- header.appendChild(headerTitle);
628
- header.appendChild(chevron);
629
-
630
- const buttonsContainer = document.createElement('div');
631
- buttonsContainer.className = 'voice-buttons';
632
-
633
- voices.forEach(({model, name, friendlyName}) => {
634
- const button = document.createElement('button');
635
- button.className = 'voice-button';
636
-
637
- // 简化显示名称
638
- const displayName = name.replace(/Neural$/, '').split('-').pop() || name;
639
- button.textContent = displayName;
640
- button.title = friendlyName;
641
-
642
- button.onclick = () => synthesize(model, button, displayName);
643
- buttonsContainer.appendChild(button);
644
- });
645
-
646
- header.onclick = () => {
647
- group.classList.toggle('open');
648
- header.classList.toggle('active');
649
- };
650
-
651
- group.appendChild(header);
652
- group.appendChild(buttonsContainer);
653
- voicesDiv.appendChild(group);
654
-
655
- // 默认展开中文语音组
656
- if (filterType === 'chinese') {
657
- group.classList.add('open');
658
- header.classList.add('active');
659
- }
660
- }
661
- }
662
-
663
- function synthesize(model, buttonElement, voiceName) {
664
- const text = document.getElementById('inputText').value || '你好,欢迎使用Edge TTS语音合成服务!';
665
- const rate = document.getElementById('rate').value || '-0.1';
666
- const pitch = document.getElementById('pitch').value || '0.1';
667
- const voice = \`rate:\${rate}|pitch:\${pitch}\`;
668
-
669
- // 重置之前的按钮状态
670
- if (currentVoiceButton) {
671
- currentVoiceButton.classList.remove('playing');
672
- currentVoiceButton.innerHTML = currentVoiceButton.textContent;
673
- }
674
-
675
- // 设置当前按钮状态
676
- currentVoiceButton = buttonElement;
677
- buttonElement.classList.add('playing');
678
- buttonElement.innerHTML = '<span class="loading"></span>' + buttonElement.textContent;
679
-
680
- // 停止之前的音频
681
- if (audio) {
682
- audio.pause();
683
- audio.currentTime = 0;
684
- }
685
-
686
- updateMobileControls(voiceName);
687
- showStatusMessage('正在合成语音,请稍候...', 'success');
688
-
689
- fetch('/v1/audio/speech', {
690
- method: 'POST',
691
- headers: {'Content-Type': 'application/json'},
692
- body: JSON.stringify({model, input: text, voice})
693
- })
694
- .then(response => {
695
- if (!response.ok) {
696
- throw new Error('合成失败');
697
- }
698
- return response.blob();
699
- })
700
- .then(blob => {
701
- const audioUrl = URL.createObjectURL(blob);
702
- audio = new Audio(audioUrl);
703
-
704
- audio.onplay = () => {
705
- showStatusMessage(\`正在播放: \${voiceName}\`, 'success');
706
- };
707
-
708
- audio.onended = () => {
709
- buttonElement.classList.remove('playing');
710
- buttonElement.innerHTML = buttonElement.textContent;
711
- updateMobileControls();
712
- showStatusMessage('播放完成', 'success');
713
- };
714
-
715
- audio.onerror = () => {
716
- buttonElement.classList.remove('playing');
717
- buttonElement.innerHTML = buttonElement.textContent;
718
- updateMobileControls();
719
- showStatusMessage('播放失败', 'error');
720
- };
721
-
722
- audio.play();
723
- })
724
- .catch(error => {
725
- buttonElement.classList.remove('playing');
726
- buttonElement.innerHTML = buttonElement.textContent;
727
- updateMobileControls();
728
- showStatusMessage('合成失败: ' + error.message, 'error');
729
- });
730
- }
731
-
732
- // 事件监听器设置
733
- document.addEventListener('DOMContentLoaded', function() {
734
- // 筛选标签页
735
- document.querySelectorAll('.filter-tab').forEach(tab => {
736
- tab.addEventListener('click', function() {
737
- document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
738
- this.classList.add('active');
739
- currentFilter = this.dataset.filter;
740
- filterVoices(currentFilter, document.getElementById('customFilter').value);
741
- });
742
- });
743
-
744
- // 自定义筛选
745
- document.getElementById('customFilter').addEventListener('input', function() {
746
- filterVoices(currentFilter, this.value);
747
- });
748
-
749
- // 滑块控制
750
- const rateSlider = document.getElementById('rate');
751
- const rateValue = document.getElementById('rateValue');
752
- rateSlider.oninput = function() {
753
- rateValue.textContent = this.value;
754
- };
755
-
756
- const pitchSlider = document.getElementById('pitch');
757
- const pitchValue = document.getElementById('pitchValue');
758
- pitchSlider.oninput = function() {
759
- pitchValue.textContent = this.value;
760
- };
761
-
762
- // 移动端控制
763
- document.getElementById('pauseBtn').addEventListener('click', function() {
764
- if (audio) {
765
- if (audio.paused) {
766
- audio.play();
767
- this.textContent = '暂停';
768
- } else {
769
- audio.pause();
770
- this.textContent = '继续';
771
- }
772
- }
773
- });
774
-
775
- document.getElementById('stopBtn').addEventListener('click', function() {
776
- if (audio) {
777
- audio.pause();
778
- audio.currentTime = 0;
779
- if (currentVoiceButton) {
780
- currentVoiceButton.classList.remove('playing');
781
- currentVoiceButton.innerHTML = currentVoiceButton.textContent;
782
- }
783
- updateMobileControls();
784
- document.getElementById('pauseBtn').textContent = '暂停';
785
- }
786
- });
787
-
788
- // 初始化
789
- filterVoices('chinese');
790
- });
791
- </script>
792
- </body>
793
- </html>`;
794
-
795
- return new Response(html, {
796
- headers: { "Content-Type": "text/html" },
797
- });
798
- }
799
-
800
  serve(async (req) => {
801
  try {
802
  const url = new URL(req.url);
 
1
+ import { serve } from "https://deno.land/std/http/server.ts";
2
+ import { EdgeSpeechTTS } from "https://esm.sh/@lobehub/tts@1";
3
+
4
+ const AUTH_TOKEN = Deno.env.get("AUTH_TOKEN");
5
+ const VOICES_URL = "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4";
6
+
7
+ async function fetchVoiceList() {
8
+ const response = await fetch(VOICES_URL);
9
+ const voices = await response.json();
10
+ return voices.reduce((acc: Record<string, { model: string, name: string, friendlyName: string, locale: string }[]>, voice: any) => {
11
+ const { ShortName: model, ShortName: name, FriendlyName: friendlyName, Locale: locale } = voice;
12
+ if (!acc[locale]) acc[locale] = [];
13
+ acc[locale].push({ model, name, friendlyName, locale });
14
+ return acc;
15
+ }, {});
16
+ }
 
 
 
 
 
 
 
 
 
17
 
18
+ async function synthesizeSpeech(model: string, voice: string, text: string) {
19
+ let voiceName;
20
+ let rate = 0;
21
+ let pitch = 0;
22
+
23
+ if (model.includes("tts")) {
24
+ rate = 0.1;
25
+ pitch = 0.2;
26
+
27
+ switch (voice) {
28
+ case "alloy":
29
+ voiceName = "zh-CN-YunjianNeural";
30
+ break;
31
+ case "echo":
32
+ voiceName = "zh-CN-YunyangNeural";
33
+ break;
34
+ case "fable":
35
+ voiceName = "zh-CN-XiaoxiaoNeural";
36
+ break;
37
+ case "onyx":
38
+ voiceName = "zh-TW-HsiaoChenNeural";
39
+ break;
40
+ default:
41
+ voiceName = "zh-CN-YunxiNeural";
42
+ break;
43
+ }
44
+ } else {
45
+ voiceName = model;
46
+ const params = Object.fromEntries(
47
+ voice.split("|").map((p) => p.split(":") as [string, string])
48
+ );
49
+ rate = Number(params["rate"] || 0);
50
+ pitch = Number(params["pitch"] || 0);
51
+ }
52
 
53
+ const tts = new EdgeSpeechTTS();
54
 
55
+ const payload = {
56
+ input: text,
57
+ options: {
58
+ rate: rate,
59
+ pitch: pitch,
60
+ voice: voiceName
61
+ },
62
+ };
63
+ const response = await tts.create(payload);
64
+ const mp3Buffer = new Uint8Array(await response.arrayBuffer());
65
+
66
+ console.log(`Successfully synthesized speech, returning audio/mpeg response`);
67
+ return new Response(mp3Buffer, {
68
+ headers: { "Content-Type": "audio/mpeg" },
69
+ });
70
+ }
71
 
72
+ function unauthorized(req: Request) {
73
+ const authHeader = req.headers.get("Authorization");
74
+ return AUTH_TOKEN && authHeader !== `Bearer ${AUTH_TOKEN}`;
75
+ }
76
 
77
+ function validateContentType(req: Request, expected: string) {
78
+ const contentType = req.headers.get("Content-Type");
79
+ if (contentType !== expected) {
80
+ console.log(`Invalid Content-Type ${contentType}, expected ${expected}`);
81
+ return new Response("Bad Request", { status: 400 });
82
+ }
83
+ }
84
 
85
+ async function handleDebugRequest(req: Request) {
86
+ const url = new URL(req.url);
87
+ const voice = url.searchParams.get("voice") || "";
88
+ const model = url.searchParams.get("model") || "";
89
+ const text = url.searchParams.get("text") || "";
90
 
91
+ console.log(`Debug request with model=${model}, voice=${voice}, text=${text}`);
92
 
93
+ if (!voice || !model || !text) {
94
+ console.log("Missing required parameters");
95
+ return new Response("Bad Request", { status: 400 });
96
+ }
97
 
98
+ return synthesizeSpeech(model, voice, text);
99
+ }
100
 
101
+ async function handleSynthesisRequest(req: Request) {
102
+ if (unauthorized(req)) {
103
+ console.log("Unauthorized request");
104
+ return new Response("Unauthorized", { status: 401 });
105
+ }
106
 
107
+ if (req.method !== "POST") {
108
+ console.log(`Invalid method ${req.method}, expected POST`);
109
+ return new Response("Method Not Allowed", { status: 405 });
110
+ }
111
 
112
+ const invalidContentType = validateContentType(req, "application/json");
113
+ if (invalidContentType) return invalidContentType;
114
 
115
+ const { model, input, voice } = await req.json();
116
+ console.log(`Synthesis request with model=${model}, input=${input}, voice=${voice}`);
117
 
118
+ return synthesizeSpeech(model, voice, input);
119
+ }
120
 
121
 
122
+ async function handleDemoRequest(req: Request) {
123
+ const groupedVoiceList = await fetchVoiceList();
124
 
125
+ const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>语音合成演示</title><link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet"><style>:root{--primary-color:#6c8bd6;--primary-light:#a2b3e3;--primary-dark:#3d5b8f;--secondary-color:#f08080;--text-color:#333;--text-secondary:#777;--bg-color:#fff}body{font-family:'Noto Sans SC','Arial',sans-serif;color:var(--text-color);margin:0;padding:0;display:flex;justify-content:center;background-color:#fafafa;background-image:linear-gradient(135deg,#f5f7fa 0%,#c3cfe2 100%);position:relative;overflow:hidden}body::before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:repeating-radial-gradient(circle at 50% 50%,rgba(255,255,255,0.8) 0%,rgba(255,255,255,0.8) 2%,transparent 2%,transparent 4%,rgba(255,255,255,0.8) 4%,rgba(255,255,255,0.8) 6%,transparent 6%,transparent 8%,rgba(255,255,255,0.8) 8%,rgba(255,255,255,0.8) 10%,transparent 10%),repeating-linear-gradient(45deg,#D4F4FF 0%,#D4F4FF 5%,#E6F9FF 5%,#E6F9FF 10%,#F0FAFF 10%,#F0FAFF 15%,#E6F9FF 15%,#E6F9FF 20%,#D4F4FF 20%,#D4F4FF 25%);background-blend-mode:multiply;opacity:0.8;z-index:-1;animation:glitch 15s infinite}.container{display:flex;max-width:1200px;width:100%;margin:40px;background:#fff;border-radius:12px;position:relative;background-color:rgba(255,255,255,0.8);z-index:1}@keyframes glitch{0%{background-position:0 0,0 0;filter:hue-rotate(0deg)}50%{background-position:10px 10px,-10px 10px;filter:hue-rotate(360deg)}100%{background-position:0 0,0 0;filter:hue-rotate(0deg)}}.input-area,.output-area{padding:30px;width:50%}.input-area{border-right:1px solid #E0E0E0}h1{font-size:36px;color:var(--primary-color);margin-bottom:30px}.filter-section{margin-bottom:30px}.filter-section label{display:block;font-size:16px;color:var(--text-secondary);margin-bottom:10px}.filter-section input{font-size:16px;padding:10px 15px;border:2px solid var(--primary-light);border-radius:8px;outline:none;transition:border-color .3s,box-shadow .3s;width:100%;box-sizing:border-box}.filter-section input:focus{border-color:var(--primary-color);box-shadow:0 0 0 2px var(--primary-light)}.slider-container{margin-bottom:30px}.slider-container label{display:block;font-size:16px;color:var(--text-secondary);margin-bottom:10px}.slider{-webkit-appearance:none;width:100%;height:10px;border-radius:5px;background:linear-gradient(to right,var(--secondary-color) 0%,var(--primary-color) 50%,var(--primary-light) 100%);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1),0 1px rgba(255,255,255,0.1);outline:none;opacity:0.7;-webkit-transition:.2s;transition:opacity .2s;margin-bottom:10px}.slider:hover{opacity:1}.slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;border-radius:50%;background:#fff;border:2px solid var(--primary-color);cursor:pointer}.slider::-moz-range-thumb{width:20px;height:20px;border-radius:50%;background:#fff;border:2px solid var(--primary-color);cursor:pointer}.slider-value{font-size:14px;color:var(--text-secondary)}.textarea-container{margin-bottom:30px}.textarea-container label{display:block;font-size:18px;margin-bottom:10px}.textarea-container textarea{width:100%;padding:10px;font-size:16px;border:2px solid var(--primary-light);border-radius:8px;outline:none;resize:vertical;transition:border-color .3s,box-shadow .3s;box-sizing:border-box;height:200px}.textarea-container textarea:focus{border-color:var(--primary-color);box-shadow:0 0 0 2px var(--primary-light)}.voice-group{margin-bottom:20px;border:2px solid var(--primary-light);border-radius:12px;overflow:hidden;cursor:move;background:#fff}.voice-header{padding:15px 20px;font-size:18px;background:var(--primary-light);color:#fff;cursor:pointer;display:flex;justify-content:space-between;align-items:center}.voice-header:hover{background:var(--primary-color)}.voice-buttons{padding:20px;display:none;gap:12px;flex-wrap:wrap}.voice-button{background:var(--secondary-color);color:#fff;border:none;padding:10px 20px;border-radius:50px;cursor:pointer;transition:filter .3s}.voice-button:hover{filter:brightness(0.9)}.chevron{transition:transform .3s}.voice-group.open .voice-buttons{display:flex}.voice-group.open .chevron{transform:rotate(180deg)}.dragging{opacity:0.5}</style></head><body><div class="container"><div class="input-area"><h1>输入文本</h1><div class="filter-section"><label for="keywords">Speaker筛选:</label><input type="text" id="keywords" value="multilingual,-TW,-CN"></div><div class="slider-container"><label for="rate">语速:</label><input type="range" min="-1" max="1" step="0.1" value="-0.1" class="slider" id="rate"><div class="slider-value" id="rateValue">-0.1</div><label for="pitch">音调:</label><input type="range" min="-1" max="1" step="0.1" value="0.1" class="slider" id="pitch"><div class="slider-value" id="pitchValue">0.1</div></div><div class="textarea-container"><label for="inputText">输入文本:</label><textarea id="inputText">Hello world</textarea></div></div><div class="output-area"><h1>选择语音</h1><div id="voices"></div></div></div><script>const voiceList = ${JSON.stringify(groupedVoiceList)};let audio=null;function filterVoices(){const keywords=document.getElementById('keywords').value.split(',').map(k=>k.trim().toLowerCase());const voicesDiv=document.getElementById('voices');voicesDiv.innerHTML='';const filteredVoices={};for(const[locale,voices]of Object.entries(voiceList)){const filtered=voices.filter(({name,friendlyName})=>keywords.some(keyword=>name.toLowerCase().includes(keyword)||friendlyName.toLowerCase().includes(keyword)));if(filtered.length>0){filteredVoices[locale]=filtered}}for(const[locale,voices]of Object.entries(filteredVoices)){const group=document.createElement('div');group.className='voice-group';group.draggable=true;const header=document.createElement('div');header.className='voice-header';header.textContent=locale.toUpperCase();const chevron=document.createElement('span');chevron.className='chevron';chevron.innerHTML='&#9660;';header.appendChild(chevron);const buttonsContainer=document.createElement('div');buttonsContainer.className='voice-buttons';voices.forEach(({model,name})=>{const button=document.createElement('button');button.className='voice-button';button.textContent=name;button.onclick=()=>synthesize(model);buttonsContainer.appendChild(button)});header.onclick=()=>{group.classList.toggle('open')};group.appendChild(header);group.appendChild(buttonsContainer);voicesDiv.appendChild(group)}addDragDropListeners()}function synthesize(model){const text=document.getElementById('inputText').value||'Hello world';const rate=document.getElementById('rate').value||'-0.1';const pitch=document.getElementById('pitch').value||'0.1';const voice=\`rate:\${rate}|pitch:\${pitch}\`;if(audio){audio.pause();audio.currentTime=0}fetch('/v1/audio/speech',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model,input:text,voice})}).then(response=>response.blob()).then(blob=>{const audioUrl=URL.createObjectURL(blob);audio=new Audio(audioUrl);audio.play()})}function addDragDropListeners(){const voicesDiv=document.getElementById('voices');let draggedItem=null;voicesDiv.addEventListener('dragstart',e=>{draggedItem=e.target;e.target.classList.add('dragging')});voicesDiv.addEventListener('dragend',e=>{e.target.classList.remove('dragging');draggedItem=null});voicesDiv.addEventListener('dragover',e=>{e.preventDefault();const afterElement=getDragAfterElement(voicesDiv,e.clientY);if(afterElement==null){voicesDiv.appendChild(draggedItem)}else{voicesDiv.insertBefore(draggedItem,afterElement)}})}function getDragAfterElement(container,y){const draggableElements=[...container.querySelectorAll('.voice-group:not(.dragging)')];return draggableElements.reduce((closest,child)=>{const box=child.getBoundingClientRect();const offset=y-box.top-box.height/2;if(offset<0&&offset>closest.offset){return{offset:offset,element:child}}else{return closest}},{offset:Number.NEGATIVE_INFINITY}).element}filterVoices();document.getElementById('keywords').addEventListener('input',filterVoices);const rateSlider=document.getElementById('rate');const rateValue=document.getElementById('rateValue');rateSlider.oninput=function(){rateValue.innerHTML=this.value};const pitchSlider=document.getElementById('pitch');const pitchValue=document.getElementById('pitchValue');pitchSlider.oninput=function(){pitchValue.innerHTML=this.value}</script></body></html>`;
126
 
127
+ return new Response(html, {
128
+ headers: { "Content-Type": "text/html" },
129
+ });
130
+ }
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  serve(async (req) => {
134
  try {
135
  const url = new URL(req.url);