|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { serve } from "https://deno.land/std/http/server.ts"; |
|
import { EdgeSpeechTTS } from "https://esm.sh/@lobehub/tts@1"; |
|
|
|
const AUTH_TOKEN = Deno.env.get("AUTH_TOKEN"); |
|
const VOICES_URL = "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4"; |
|
|
|
async function fetchVoiceList() { |
|
const response = await fetch(VOICES_URL); |
|
const voices = await response.json(); |
|
return voices.reduce((acc: Record<string, { model: string, name: string, friendlyName: string, locale: string }[]>, voice: any) => { |
|
const { ShortName: model, ShortName: name, FriendlyName: friendlyName, Locale: locale } = voice; |
|
if (!acc[locale]) acc[locale] = []; |
|
acc[locale].push({ model, name, friendlyName, locale }); |
|
return acc; |
|
}, {}); |
|
} |
|
|
|
async function synthesizeSpeech(model: string, voice: string, text: string) { |
|
let voiceName; |
|
let rate = 0; |
|
let pitch = 0; |
|
|
|
if (model.includes("tts")) { |
|
rate = 0.1; |
|
pitch = 0.2; |
|
|
|
switch (voice) { |
|
case "alloy": |
|
voiceName = "zh-CN-YunjianNeural"; |
|
break; |
|
case "echo": |
|
voiceName = "zh-CN-YunyangNeural"; |
|
break; |
|
case "fable": |
|
voiceName = "zh-CN-XiaoxiaoNeural"; |
|
break; |
|
case "onyx": |
|
voiceName = "zh-TW-HsiaoChenNeural"; |
|
break; |
|
default: |
|
voiceName = "zh-CN-YunxiNeural"; |
|
break; |
|
} |
|
} else { |
|
voiceName = model; |
|
const params = Object.fromEntries( |
|
voice.split("|").map((p) => p.split(":") as [string, string]) |
|
); |
|
rate = Number(params["rate"] || 0); |
|
pitch = Number(params["pitch"] || 0); |
|
} |
|
|
|
const tts = new EdgeSpeechTTS(); |
|
|
|
const payload = { |
|
input: text, |
|
options: { |
|
rate: rate, |
|
pitch: pitch, |
|
voice: voiceName |
|
}, |
|
}; |
|
const response = await tts.create(payload); |
|
const mp3Buffer = new Uint8Array(await response.arrayBuffer()); |
|
|
|
console.log(`Successfully synthesized speech, returning audio/mpeg response`); |
|
return new Response(mp3Buffer, { |
|
headers: { "Content-Type": "audio/mpeg" }, |
|
}); |
|
} |
|
|
|
function unauthorized(req: Request) { |
|
const authHeader = req.headers.get("Authorization"); |
|
return AUTH_TOKEN && authHeader !== `Bearer ${AUTH_TOKEN}`; |
|
} |
|
|
|
function validateContentType(req: Request, expected: string) { |
|
const contentType = req.headers.get("Content-Type"); |
|
if (contentType !== expected) { |
|
console.log(`Invalid Content-Type ${contentType}, expected ${expected}`); |
|
return new Response("Bad Request", { status: 400 }); |
|
} |
|
} |
|
|
|
async function handleDebugRequest(req: Request) { |
|
const url = new URL(req.url); |
|
const voice = url.searchParams.get("voice") || ""; |
|
const model = url.searchParams.get("model") || ""; |
|
const text = url.searchParams.get("text") || ""; |
|
|
|
console.log(`Debug request with model=${model}, voice=${voice}, text=${text}`); |
|
|
|
if (!voice || !model || !text) { |
|
console.log("Missing required parameters"); |
|
return new Response("Bad Request", { status: 400 }); |
|
} |
|
|
|
return synthesizeSpeech(model, voice, text); |
|
} |
|
|
|
async function handleSynthesisRequest(req: Request) { |
|
if (unauthorized(req)) { |
|
console.log("Unauthorized request"); |
|
return new Response("Unauthorized", { status: 401 }); |
|
} |
|
|
|
if (req.method !== "POST") { |
|
console.log(`Invalid method ${req.method}, expected POST`); |
|
return new Response("Method Not Allowed", { status: 405 }); |
|
} |
|
|
|
const invalidContentType = validateContentType(req, "application/json"); |
|
if (invalidContentType) return invalidContentType; |
|
|
|
const { model, input, voice } = await req.json(); |
|
console.log(`Synthesis request with model=${model}, input=${input}, voice=${voice}`); |
|
|
|
return synthesizeSpeech(model, voice, input); |
|
} |
|
|
|
async function handleDemoRequest(req: Request) { |
|
const groupedVoiceList = await fetchVoiceList(); |
|
|
|
const html = `<!DOCTYPE html> |
|
<html lang="zh-CN"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
|
<title>Edge TTS 语音合成演示</title> |
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet"> |
|
<style> |
|
:root { |
|
--primary-color: #1890ff; |
|
--primary-light: #40a9ff; |
|
--primary-dark: #096dd9; |
|
--secondary-color: #52c41a; |
|
--accent-color: #722ed1; |
|
--text-color: #262626; |
|
--text-secondary: #8c8c8c; |
|
--bg-color: #ffffff; |
|
--bg-secondary: #fafafa; |
|
--border-color: #d9d9d9; |
|
--border-radius: 8px; |
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
--shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.15); |
|
} |
|
|
|
* { |
|
box-sizing: border-box; |
|
margin: 0; |
|
padding: 0; |
|
} |
|
|
|
body { |
|
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
color: var(--text-color); |
|
min-height: 100vh; |
|
line-height: 1.6; |
|
overflow-x: hidden; |
|
} |
|
|
|
.container { |
|
max-width: 1200px; |
|
margin: 0 auto; |
|
padding: 20px; |
|
min-height: 100vh; |
|
} |
|
|
|
.header { |
|
text-align: center; |
|
margin-bottom: 30px; |
|
color: white; |
|
} |
|
|
|
.header h1 { |
|
font-size: clamp(1.8rem, 4vw, 3rem); |
|
font-weight: 700; |
|
margin-bottom: 10px; |
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); |
|
} |
|
|
|
.header p { |
|
font-size: clamp(1rem, 2.5vw, 1.2rem); |
|
opacity: 0.9; |
|
font-weight: 300; |
|
} |
|
|
|
.main-content { |
|
display: grid; |
|
grid-template-columns: 1fr; |
|
gap: 20px; |
|
background: var(--bg-color); |
|
border-radius: var(--border-radius); |
|
box-shadow: var(--shadow); |
|
overflow: hidden; |
|
} |
|
|
|
@media (min-width: 768px) { |
|
.main-content { |
|
grid-template-columns: 1fr 1fr; |
|
} |
|
} |
|
|
|
.panel { |
|
padding: 24px; |
|
} |
|
|
|
.panel-title { |
|
font-size: 1.5rem; |
|
font-weight: 600; |
|
color: var(--primary-color); |
|
margin-bottom: 20px; |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
} |
|
|
|
.input-panel { |
|
border-right: none; |
|
} |
|
|
|
@media (min-width: 768px) { |
|
.input-panel { |
|
border-right: 1px solid var(--border-color); |
|
} |
|
} |
|
|
|
.form-group { |
|
margin-bottom: 20px; |
|
} |
|
|
|
.form-label { |
|
display: block; |
|
font-weight: 500; |
|
color: var(--text-color); |
|
margin-bottom: 8px; |
|
font-size: 0.9rem; |
|
} |
|
|
|
.form-input { |
|
width: 100%; |
|
padding: 12px 16px; |
|
border: 2px solid var(--border-color); |
|
border-radius: var(--border-radius); |
|
font-size: 14px; |
|
transition: all 0.3s ease; |
|
background: var(--bg-color); |
|
} |
|
|
|
.form-input:focus { |
|
outline: none; |
|
border-color: var(--primary-color); |
|
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1); |
|
} |
|
|
|
.form-textarea { |
|
min-height: 120px; |
|
resize: vertical; |
|
font-family: inherit; |
|
} |
|
|
|
.slider-container { |
|
margin-bottom: 20px; |
|
} |
|
|
|
.slider-wrapper { |
|
position: relative; |
|
margin-bottom: 8px; |
|
} |
|
|
|
.slider { |
|
width: 100%; |
|
height: 6px; |
|
border-radius: 3px; |
|
background: var(--border-color); |
|
outline: none; |
|
-webkit-appearance: none; |
|
appearance: none; |
|
cursor: pointer; |
|
} |
|
|
|
.slider::-webkit-slider-thumb { |
|
-webkit-appearance: none; |
|
appearance: none; |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 50%; |
|
background: var(--primary-color); |
|
cursor: pointer; |
|
border: 2px solid white; |
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.slider::-webkit-slider-thumb:hover { |
|
transform: scale(1.1); |
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); |
|
} |
|
|
|
.slider::-moz-range-thumb { |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 50%; |
|
background: var(--primary-color); |
|
cursor: pointer; |
|
border: 2px solid white; |
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); |
|
} |
|
|
|
.slider-value { |
|
font-size: 0.85rem; |
|
color: var(--text-secondary); |
|
text-align: center; |
|
background: var(--bg-secondary); |
|
padding: 4px 8px; |
|
border-radius: 4px; |
|
min-width: 60px; |
|
} |
|
|
|
.voice-container { |
|
max-height: 60vh; |
|
overflow-y: auto; |
|
border: 1px solid var(--border-color); |
|
border-radius: var(--border-radius); |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.voice-container::-webkit-scrollbar { |
|
width: 6px; |
|
} |
|
|
|
.voice-container::-webkit-scrollbar-track { |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.voice-container::-webkit-scrollbar-thumb { |
|
background: var(--border-color); |
|
border-radius: 3px; |
|
} |
|
|
|
.voice-container::-webkit-scrollbar-thumb:hover { |
|
background: var(--text-secondary); |
|
} |
|
|
|
.voice-group { |
|
border-bottom: 1px solid var(--border-color); |
|
background: white; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.voice-group:last-child { |
|
border-bottom: none; |
|
} |
|
|
|
.voice-header { |
|
padding: 16px 20px; |
|
cursor: pointer; |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
background: var(--bg-color); |
|
transition: all 0.3s ease; |
|
border-left: 4px solid transparent; |
|
} |
|
|
|
.voice-header:hover { |
|
background: var(--bg-secondary); |
|
border-left-color: var(--primary-color); |
|
} |
|
|
|
.voice-header.active { |
|
background: var(--primary-color); |
|
color: white; |
|
border-left-color: var(--primary-dark); |
|
} |
|
|
|
.voice-header-title { |
|
font-weight: 500; |
|
font-size: 0.95rem; |
|
} |
|
|
|
.voice-header-count { |
|
font-size: 0.8rem; |
|
opacity: 0.8; |
|
background: rgba(255, 255, 255, 0.2); |
|
padding: 2px 8px; |
|
border-radius: 12px; |
|
margin-left: 8px; |
|
} |
|
|
|
.chevron { |
|
transition: transform 0.3s ease; |
|
font-size: 0.8rem; |
|
} |
|
|
|
.voice-group.open .chevron { |
|
transform: rotate(180deg); |
|
} |
|
|
|
.voice-buttons { |
|
padding: 16px 20px; |
|
display: none; |
|
gap: 8px; |
|
flex-wrap: wrap; |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.voice-group.open .voice-buttons { |
|
display: flex; |
|
} |
|
|
|
.voice-button { |
|
background: white; |
|
color: var(--text-color); |
|
border: 1px solid var(--border-color); |
|
padding: 8px 16px; |
|
border-radius: 20px; |
|
cursor: pointer; |
|
transition: all 0.3s ease; |
|
font-size: 0.85rem; |
|
font-weight: 400; |
|
white-space: nowrap; |
|
position: relative; |
|
overflow: hidden; |
|
} |
|
|
|
.voice-button:hover { |
|
border-color: var(--primary-color); |
|
color: var(--primary-color); |
|
transform: translateY(-1px); |
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2); |
|
} |
|
|
|
.voice-button:active { |
|
transform: translateY(0); |
|
} |
|
|
|
.voice-button.playing { |
|
background: var(--secondary-color); |
|
color: white; |
|
border-color: var(--secondary-color); |
|
animation: pulse 1.5s infinite; |
|
} |
|
|
|
@keyframes pulse { |
|
0% { transform: scale(1); } |
|
50% { transform: scale(1.05); } |
|
100% { transform: scale(1); } |
|
} |
|
|
|
.chinese-voices { |
|
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%); |
|
} |
|
|
|
.chinese-voices .voice-header { |
|
background: rgba(255, 255, 255, 0.9); |
|
} |
|
|
|
.chinese-voices .voice-header:hover { |
|
background: rgba(255, 255, 255, 1); |
|
} |
|
|
|
.chinese-voices .voice-header.active { |
|
background: var(--accent-color); |
|
} |
|
|
|
.loading { |
|
display: inline-block; |
|
width: 16px; |
|
height: 16px; |
|
border: 2px solid var(--border-color); |
|
border-radius: 50%; |
|
border-top-color: var(--primary-color); |
|
animation: spin 1s linear infinite; |
|
margin-right: 8px; |
|
} |
|
|
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
|
|
.status-message { |
|
margin-top: 16px; |
|
padding: 12px 16px; |
|
border-radius: var(--border-radius); |
|
font-size: 0.9rem; |
|
text-align: center; |
|
display: none; |
|
} |
|
|
|
.status-message.show { |
|
display: block; |
|
} |
|
|
|
.status-message.success { |
|
background: #f6ffed; |
|
color: #52c41a; |
|
border: 1px solid #b7eb8f; |
|
} |
|
|
|
.status-message.error { |
|
background: #fff2f0; |
|
color: #ff4d4f; |
|
border: 1px solid #ffccc7; |
|
} |
|
|
|
.mobile-controls { |
|
position: fixed; |
|
bottom: 0; |
|
left: 0; |
|
right: 0; |
|
background: white; |
|
padding: 16px 20px; |
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1); |
|
border-top: 1px solid var(--border-color); |
|
display: none; |
|
z-index: 1000; |
|
} |
|
|
|
@media (max-width: 767px) { |
|
.mobile-controls { |
|
display: block; |
|
} |
|
|
|
.voice-container { |
|
margin-bottom: 80px; |
|
} |
|
} |
|
|
|
.current-audio-info { |
|
font-size: 0.8rem; |
|
color: var(--text-secondary); |
|
margin-bottom: 8px; |
|
} |
|
|
|
.audio-controls { |
|
display: flex; |
|
gap: 12px; |
|
align-items: center; |
|
} |
|
|
|
.control-button { |
|
background: var(--primary-color); |
|
color: white; |
|
border: none; |
|
padding: 10px 16px; |
|
border-radius: var(--border-radius); |
|
cursor: pointer; |
|
font-size: 0.9rem; |
|
transition: all 0.3s ease; |
|
flex: 1; |
|
} |
|
|
|
.control-button:hover { |
|
background: var(--primary-dark); |
|
transform: translateY(-1px); |
|
} |
|
|
|
.control-button:disabled { |
|
background: var(--border-color); |
|
color: var(--text-secondary); |
|
cursor: not-allowed; |
|
transform: none; |
|
} |
|
|
|
@media (max-width: 480px) { |
|
.container { |
|
padding: 10px; |
|
} |
|
|
|
.panel { |
|
padding: 16px; |
|
} |
|
|
|
.voice-button { |
|
padding: 6px 12px; |
|
font-size: 0.8rem; |
|
} |
|
} |
|
|
|
.filter-section { |
|
margin-bottom: 20px; |
|
padding: 16px; |
|
background: var(--bg-secondary); |
|
border-radius: var(--border-radius); |
|
border: 1px solid var(--border-color); |
|
} |
|
|
|
.filter-tabs { |
|
display: flex; |
|
gap: 8px; |
|
margin-bottom: 12px; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.filter-tab { |
|
padding: 6px 12px; |
|
border: 1px solid var(--border-color); |
|
border-radius: 16px; |
|
background: white; |
|
cursor: pointer; |
|
font-size: 0.8rem; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.filter-tab.active { |
|
background: var(--primary-color); |
|
color: white; |
|
border-color: var(--primary-color); |
|
} |
|
|
|
.filter-tab:hover:not(.active) { |
|
border-color: var(--primary-light); |
|
color: var(--primary-color); |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
<div class="container"> |
|
<div class="header"> |
|
<h1>🎵 Edge TTS 语音合成</h1> |
|
<p>支持多语言高质量语音合成,特别优化中文语音体验</p> |
|
</div> |
|
|
|
<div class="main-content"> |
|
<div class="panel input-panel"> |
|
<h2 class="panel-title"> |
|
📝 输入设置 |
|
</h2> |
|
|
|
<div class="filter-section"> |
|
<div class="filter-tabs"> |
|
<div class="filter-tab active" data-filter="chinese">中文语音</div> |
|
<div class="filter-tab" data-filter="english">英文语音</div> |
|
<div class="filter-tab" data-filter="multilingual">多语言</div> |
|
<div class="filter-tab" data-filter="all">全部语音</div> |
|
</div> |
|
<input type="text" id="customFilter" class="form-input" placeholder="自定义筛选关键词..."> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label class="form-label" for="rate">语速调节</label> |
|
<div class="slider-wrapper"> |
|
<input type="range" min="-1" max="1" step="0.1" value="-0.1" class="slider" id="rate"> |
|
</div> |
|
<div class="slider-value" id="rateValue">-0.1</div> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label class="form-label" for="pitch">音调调节</label> |
|
<div class="slider-wrapper"> |
|
<input type="range" min="-1" max="1" step="0.1" value="0.1" class="slider" id="pitch"> |
|
</div> |
|
<div class="slider-value" id="pitchValue">0.1</div> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label class="form-label" for="inputText">输入文本</label> |
|
<textarea id="inputText" class="form-input form-textarea" placeholder="请输入要转换为语音的文本内容...">你好,欢迎使用Edge TTS语音合成服务!这里支持多种中文语音选择。</textarea> |
|
</div> |
|
|
|
<div class="status-message" id="statusMessage"></div> |
|
</div> |
|
|
|
<div class="panel"> |
|
<h2 class="panel-title"> |
|
🎤 语音选择 |
|
</h2> |
|
|
|
<div class="voice-container" id="voices"></div> |
|
</div> |
|
</div> |
|
|
|
<div class="mobile-controls"> |
|
<div class="current-audio-info" id="currentAudioInfo">选择语音后开始合成</div> |
|
<div class="audio-controls"> |
|
<button class="control-button" id="pauseBtn" disabled>暂停</button> |
|
<button class="control-button" id="stopBtn" disabled>停止</button> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
const voiceList = ${JSON.stringify(groupedVoiceList)}; |
|
let audio = null; |
|
let currentVoiceButton = null; |
|
let currentFilter = 'chinese'; |
|
|
|
// 中文语音配置 |
|
const chineseVoiceMapping = { |
|
'zh-CN': '中文 (普通话)', |
|
'zh-HK': '中文 (粤语)', |
|
'zh-TW': '中文 (台湾话)', |
|
'zh-CN-liaoning': '中文 (辽宁话)', |
|
'zh-CN-shaanxi': '中文 (陕西话)' |
|
}; |
|
|
|
const filterPresets = { |
|
chinese: ['zh-CN', 'zh-HK', 'zh-TW', 'zh-CN-liaoning', 'zh-CN-shaanxi'], |
|
english: ['en-US', 'en-GB', 'en-AU', 'en-CA', 'en-IN'], |
|
multilingual: Object.keys(voiceList).filter(locale => |
|
!locale.startsWith('zh-') && !locale.startsWith('en-') |
|
).slice(0, 10) |
|
}; |
|
|
|
function showStatusMessage(message, type = 'success') { |
|
const statusEl = document.getElementById('statusMessage'); |
|
statusEl.textContent = message; |
|
statusEl.className = \`status-message show \${type}\`; |
|
setTimeout(() => { |
|
statusEl.classList.remove('show'); |
|
}, 3000); |
|
} |
|
|
|
function updateMobileControls(voiceName = '') { |
|
const infoEl = document.getElementById('currentAudioInfo'); |
|
const pauseBtn = document.getElementById('pauseBtn'); |
|
const stopBtn = document.getElementById('stopBtn'); |
|
|
|
if (voiceName) { |
|
infoEl.textContent = \`当前语音: \${voiceName}\`; |
|
pauseBtn.disabled = false; |
|
stopBtn.disabled = false; |
|
} else { |
|
infoEl.textContent = '选择语音后开始合成'; |
|
pauseBtn.disabled = true; |
|
stopBtn.disabled = true; |
|
} |
|
} |
|
|
|
function filterVoices(filterType = 'chinese', customKeyword = '') { |
|
const voicesDiv = document.getElementById('voices'); |
|
voicesDiv.innerHTML = ''; |
|
|
|
let filteredVoices = {}; |
|
|
|
if (filterType === 'all') { |
|
filteredVoices = voiceList; |
|
} else if (filterPresets[filterType]) { |
|
for (const locale of filterPresets[filterType]) { |
|
if (voiceList[locale]) { |
|
filteredVoices[locale] = voiceList[locale]; |
|
} |
|
} |
|
} |
|
|
|
// 应用自定义关键词过滤 |
|
if (customKeyword.trim()) { |
|
const keyword = customKeyword.trim().toLowerCase(); |
|
const tempFiltered = {}; |
|
|
|
for (const [locale, voices] of Object.entries(filteredVoices)) { |
|
const matchingVoices = voices.filter(voice => |
|
voice.name.toLowerCase().includes(keyword) || |
|
voice.friendlyName.toLowerCase().includes(keyword) || |
|
locale.toLowerCase().includes(keyword) |
|
); |
|
|
|
if (matchingVoices.length > 0) { |
|
tempFiltered[locale] = matchingVoices; |
|
} |
|
} |
|
|
|
filteredVoices = tempFiltered; |
|
} |
|
|
|
// 渲染语音组 |
|
for (const [locale, voices] of Object.entries(filteredVoices)) { |
|
const group = document.createElement('div'); |
|
group.className = \`voice-group \${filterType === 'chinese' ? 'chinese-voices' : ''}\`; |
|
|
|
const header = document.createElement('div'); |
|
header.className = 'voice-header'; |
|
|
|
const displayName = chineseVoiceMapping[locale] || locale.toUpperCase(); |
|
const headerTitle = document.createElement('div'); |
|
headerTitle.innerHTML = \` |
|
<span class="voice-header-title">\${displayName}</span> |
|
<span class="voice-header-count">\${voices.length}个</span> |
|
\`; |
|
|
|
const chevron = document.createElement('span'); |
|
chevron.className = 'chevron'; |
|
chevron.innerHTML = '▼'; |
|
|
|
header.appendChild(headerTitle); |
|
header.appendChild(chevron); |
|
|
|
const buttonsContainer = document.createElement('div'); |
|
buttonsContainer.className = 'voice-buttons'; |
|
|
|
voices.forEach(({model, name, friendlyName}) => { |
|
const button = document.createElement('button'); |
|
button.className = 'voice-button'; |
|
|
|
// 简化显示名称 |
|
const displayName = name.replace(/Neural$/, '').split('-').pop() || name; |
|
button.textContent = displayName; |
|
button.title = friendlyName; |
|
|
|
button.onclick = () => synthesize(model, button, displayName); |
|
buttonsContainer.appendChild(button); |
|
}); |
|
|
|
header.onclick = () => { |
|
group.classList.toggle('open'); |
|
header.classList.toggle('active'); |
|
}; |
|
|
|
group.appendChild(header); |
|
group.appendChild(buttonsContainer); |
|
voicesDiv.appendChild(group); |
|
|
|
// 默认展开中文语音组 |
|
if (filterType === 'chinese') { |
|
group.classList.add('open'); |
|
header.classList.add('active'); |
|
} |
|
} |
|
} |
|
|
|
function synthesize(model, buttonElement, voiceName) { |
|
const text = document.getElementById('inputText').value || '你好,欢迎使用Edge TTS语音合成服务!'; |
|
const rate = document.getElementById('rate').value || '-0.1'; |
|
const pitch = document.getElementById('pitch').value || '0.1'; |
|
const voice = \`rate:\${rate}|pitch:\${pitch}\`; |
|
|
|
// 重置之前的按钮状态 |
|
if (currentVoiceButton) { |
|
currentVoiceButton.classList.remove('playing'); |
|
currentVoiceButton.innerHTML = currentVoiceButton.textContent; |
|
} |
|
|
|
// 设置当前按钮状态 |
|
currentVoiceButton = buttonElement; |
|
buttonElement.classList.add('playing'); |
|
buttonElement.innerHTML = '<span class="loading"></span>' + buttonElement.textContent; |
|
|
|
// 停止之前的音频 |
|
if (audio) { |
|
audio.pause(); |
|
audio.currentTime = 0; |
|
} |
|
|
|
updateMobileControls(voiceName); |
|
showStatusMessage('正在合成语音,请稍候...', 'success'); |
|
|
|
fetch('/v1/audio/speech', { |
|
method: 'POST', |
|
headers: {'Content-Type': 'application/json'}, |
|
body: JSON.stringify({model, input: text, voice}) |
|
}) |
|
.then(response => { |
|
if (!response.ok) { |
|
throw new Error('合成失败'); |
|
} |
|
return response.blob(); |
|
}) |
|
.then(blob => { |
|
const audioUrl = URL.createObjectURL(blob); |
|
audio = new Audio(audioUrl); |
|
|
|
audio.onplay = () => { |
|
showStatusMessage(\`正在播放: \${voiceName}\`, 'success'); |
|
}; |
|
|
|
audio.onended = () => { |
|
buttonElement.classList.remove('playing'); |
|
buttonElement.innerHTML = buttonElement.textContent; |
|
updateMobileControls(); |
|
showStatusMessage('播放完成', 'success'); |
|
}; |
|
|
|
audio.onerror = () => { |
|
buttonElement.classList.remove('playing'); |
|
buttonElement.innerHTML = buttonElement.textContent; |
|
updateMobileControls(); |
|
showStatusMessage('播放失败', 'error'); |
|
}; |
|
|
|
audio.play(); |
|
}) |
|
.catch(error => { |
|
buttonElement.classList.remove('playing'); |
|
buttonElement.innerHTML = buttonElement.textContent; |
|
updateMobileControls(); |
|
showStatusMessage('合成失败: ' + error.message, 'error'); |
|
}); |
|
} |
|
|
|
// 事件监听器设置 |
|
document.addEventListener('DOMContentLoaded', function() { |
|
// 筛选标签页 |
|
document.querySelectorAll('.filter-tab').forEach(tab => { |
|
tab.addEventListener('click', function() { |
|
document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active')); |
|
this.classList.add('active'); |
|
currentFilter = this.dataset.filter; |
|
filterVoices(currentFilter, document.getElementById('customFilter').value); |
|
}); |
|
}); |
|
|
|
// 自定义筛选 |
|
document.getElementById('customFilter').addEventListener('input', function() { |
|
filterVoices(currentFilter, this.value); |
|
}); |
|
|
|
// 滑块控制 |
|
const rateSlider = document.getElementById('rate'); |
|
const rateValue = document.getElementById('rateValue'); |
|
rateSlider.oninput = function() { |
|
rateValue.textContent = this.value; |
|
}; |
|
|
|
const pitchSlider = document.getElementById('pitch'); |
|
const pitchValue = document.getElementById('pitchValue'); |
|
pitchSlider.oninput = function() { |
|
pitchValue.textContent = this.value; |
|
}; |
|
|
|
// 移动端控制 |
|
document.getElementById('pauseBtn').addEventListener('click', function() { |
|
if (audio) { |
|
if (audio.paused) { |
|
audio.play(); |
|
this.textContent = '暂停'; |
|
} else { |
|
audio.pause(); |
|
this.textContent = '继续'; |
|
} |
|
} |
|
}); |
|
|
|
document.getElementById('stopBtn').addEventListener('click', function() { |
|
if (audio) { |
|
audio.pause(); |
|
audio.currentTime = 0; |
|
if (currentVoiceButton) { |
|
currentVoiceButton.classList.remove('playing'); |
|
currentVoiceButton.innerHTML = currentVoiceButton.textContent; |
|
} |
|
updateMobileControls(); |
|
document.getElementById('pauseBtn').textContent = '暂停'; |
|
} |
|
}); |
|
|
|
// 初始化 |
|
filterVoices('chinese'); |
|
}); |
|
</script> |
|
</body> |
|
</html>`; |
|
|
|
return new Response(html, { |
|
headers: { "Content-Type": "text/html" }, |
|
}); |
|
} |
|
|
|
serve(async (req) => { |
|
try { |
|
const url = new URL(req.url); |
|
|
|
if (url.pathname === "/") { |
|
return handleDemoRequest(req); |
|
} |
|
|
|
if (url.pathname === "/tts") { |
|
return handleDebugRequest(req); |
|
} |
|
|
|
if (url.pathname !== "/v1/audio/speech") { |
|
console.log(`Unhandled path ${url.pathname}`); |
|
return new Response("Not Found", { status: 404 }); |
|
} |
|
|
|
return handleSynthesisRequest(req); |
|
} catch (err) { |
|
console.error(`Error processing request: ${err.message}`); |
|
return new Response(`Internal Server Error\n${err.message}`, { |
|
status: 500, |
|
}); |
|
} |
|
}); |