From c9f1e41690138abe0d85e2114615a3ce4ee6c9d2 Mon Sep 17 00:00:00 2001 From: sun <3371392206@qq.com> Date: Sat, 3 Jan 2026 00:16:36 +0800 Subject: [PATCH] =?UTF-8?q?Reapply=20"feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96O?= =?UTF-8?q?penAITTS=E9=85=8D=E9=9F=B3=E5=8F=B0=E9=A1=B9=E7=9B=AE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e7cd2903257304450a696e6b01f832bde558d305. --- README.md | 156 ++++++++++++++++++++ app.js | 221 ++++++++++++++++++++++++++++ config.example.json | 4 + index.html | 94 ++++++++++++ main.go | 149 +++++++++++++++++++ styles.css | 344 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 968 insertions(+) create mode 100644 README.md create mode 100644 app.js create mode 100644 config.example.json create mode 100644 index.html create mode 100644 main.go create mode 100644 styles.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0f1fbe --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# OpenAITTS配音台 + +一个简洁高效的文本转语音工具,支持自定义OpenAI API地址,基于Go后端和浏览器前端实现。 + +## 功能特点 + +- ✨ 简洁大方的用户界面 +- 🔧 支持自定义API地址 +- 🎤 多种声音模型选择 +- 📱 响应式设计,适配各种设备 +- 💾 本地存储API配置 +- ⚡ 快速生成和播放语音 +- 📥 支持音频下载 + +## 技术栈 + +- **后端**: Go 1.18+ +- **前端**: HTML5 + CSS3 + JavaScript (ES6+) +- **API**: OpenAI TTS API + +## 配置说明 + +### 1. 配置文件方式 + +复制 `config.example.json` 为 `config.json`,然后修改其中的配置: + +```json +{ + "api_base_url": "https://your-api-address.com", + "port": "8080" +} +``` + +### 2. 环境变量方式 + +你也可以通过环境变量来配置: + +```bash +export OPENAI_API_BASE_URL="https://your-api-address.com" +export PORT="8080" +``` + +### 3. 前端直接配置 + +在浏览器界面中,你可以直接输入API Key和API Base URL,这些配置会自动保存到浏览器本地存储中。 + +## 快速开始 + +### 前提条件 + +- 已安装Go 1.18或更高版本 +- 有可用的OpenAI API Key +- 自定义API地址(如果需要) + +### 启动服务 + +在项目根目录下执行: + +```bash +go run main.go +``` + +或者使用配置文件: + +```bash +go run main.go -config=config.json +``` + +服务启动后,会在控制台输出: + +``` +2024/01/01 12:00:00 Server starting on port 8080... +2024/01/01 12:00:00 Using API base URL: https://api.openai.com +2024/01/01 12:00:00 Open http://localhost:8080 in your browser +``` + +### 使用方法 + +1. 在浏览器中打开 `http://localhost:8080` +2. 在API配置区域输入你的API Key和自定义API地址 +3. 在文本输入区域输入要转换为语音的文本 +4. 选择模型和声音参数 +5. 点击"生成语音"按钮 +6. 生成完成后,可以播放或下载音频 + +## 支持的声音模型 + +- **tts-1**: 标准模型,速度快 +- **tts-1-hd**: 高清模型,质量更高 + +## 支持的声音类型 + +- **alloy**: 清晰自然的声音 +- **echo**: 沉稳有力的声音 +- **fable**: 温暖亲切的声音 +- **onyx**: 成熟稳重的声音 +- **nova**: 活泼开朗的声音 +- **shimmer**: 柔美动听的声音 + +## 项目结构 + +``` +. +├── main.go # Go后端主程序 +├── index.html # 前端HTML页面 +├── styles.css # 前端CSS样式 +├── app.js # 前端JavaScript +├── config.example.json # 配置文件示例 +└── README.md # 项目说明文档 +``` + +## API端点 + +### POST /api/tts + +生成语音的API端点,请求体格式: + +```json +{ + "api_key": "sk-...", + "text": "要转换的文本", + "voice": "alloy", + "model": "tts-1-hd", + "api_base": "https://your-api-address.com" +} +``` + +响应: +- 成功:返回MP3音频文件(Content-Type: audio/mpeg) +- 失败:返回JSON格式的错误信息 + +## 注意事项 + +1. 请确保你的API Key有效且有足够的配额 +2. 自定义API地址需要兼容OpenAI TTS API的接口格式 +3. 文本长度建议不超过4096个字符 +4. 生成的音频文件会自动下载到你的默认下载目录 + +## 浏览器支持 + +- Chrome 60+ +- Firefox 55+ +- Safari 12+ +- Edge 79+ + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交Issue和Pull Request! + +## 联系方式 + +如有问题或建议,请通过GitHub Issues反馈。 diff --git a/app.js b/app.js new file mode 100644 index 0000000..1cafae0 --- /dev/null +++ b/app.js @@ -0,0 +1,221 @@ +// DOM元素获取 +const apiKeyInput = document.getElementById('apiKey'); +const apiBaseInput = document.getElementById('apiBase'); +const textInput = document.getElementById('textInput'); +const charCount = document.getElementById('charCount'); +const modelSelect = document.getElementById('model'); +const voiceSelect = document.getElementById('voice'); +const generateBtn = document.getElementById('generateBtn'); +const playBtn = document.getElementById('playBtn'); +const downloadBtn = document.getElementById('downloadBtn'); +const statusDiv = document.getElementById('status'); +const audioPlayer = document.getElementById('audioPlayer'); + +// 当前生成的音频URL +let currentAudioURL = null; + +// 初始化函数 +function init() { + // 加载本地存储的配置 + loadConfig(); + + // 绑定事件监听器 + bindEventListeners(); + + // 初始状态更新 + updateCharCount(); +} + +// 加载本地存储的配置 +function loadConfig() { + const savedAPIKey = localStorage.getItem('openai_api_key'); + const savedAPIBase = localStorage.getItem('openai_api_base'); + + if (savedAPIKey) { + apiKeyInput.value = savedAPIKey; + } + + if (savedAPIBase) { + apiBaseInput.value = savedAPIBase; + } +} + +// 保存配置到本地存储 +function saveConfig() { + localStorage.setItem('openai_api_key', apiKeyInput.value); + localStorage.setItem('openai_api_base', apiBaseInput.value); +} + +// 绑定事件监听器 +function bindEventListeners() { + // 文本输入事件 - 更新字符计数 + textInput.addEventListener('input', updateCharCount); + + // API配置输入事件 - 保存到本地存储 + apiKeyInput.addEventListener('input', saveConfig); + apiBaseInput.addEventListener('input', saveConfig); + + // 生成语音按钮点击事件 + generateBtn.addEventListener('click', generateSpeech); + + // 播放按钮点击事件 + playBtn.addEventListener('click', togglePlay); + + // 下载按钮点击事件 + downloadBtn.addEventListener('click', downloadAudio); + + // 音频播放结束事件 + audioPlayer.addEventListener('ended', onAudioEnded); +} + +// 更新字符计数 +function updateCharCount() { + const count = textInput.value.length; + charCount.textContent = count; +} + +// 生成语音 +async function generateSpeech() { + // 验证输入 + if (!validateInput()) { + return; + } + + // 保存配置 + saveConfig(); + + // 更新状态 + setStatus('loading', '正在生成语音...'); + disableControls(); + + try { + // 构建请求数据 + const requestData = { + api_key: apiKeyInput.value, + text: textInput.value, + voice: voiceSelect.value, + model: modelSelect.value, + api_base: apiBaseInput.value + }; + + // 发送请求到后端API + const response = await fetch('/api/tts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ + error: `HTTP错误: ${response.status} ${response.statusText}` + })); + throw new Error(errorData.error || '生成语音失败'); + } + + // 获取音频Blob + const audioBlob = await response.blob(); + + // 创建音频URL + if (currentAudioURL) { + URL.revokeObjectURL(currentAudioURL); + } + currentAudioURL = URL.createObjectURL(audioBlob); + + // 更新音频播放器 + audioPlayer.src = currentAudioURL; + + // 更新状态 + setStatus('success', '语音生成成功!'); + enableControls(); + + } catch (error) { + // 处理错误 + setStatus('error', `生成失败: ${error.message}`); + disableControls(); + } +} + +// 验证输入 +function validateInput() { + if (!apiKeyInput.value.trim()) { + setStatus('error', '请输入API Key'); + apiKeyInput.focus(); + return false; + } + + if (!textInput.value.trim()) { + setStatus('error', '请输入要转换的文本'); + textInput.focus(); + return false; + } + + return true; +} + +// 切换播放状态 +function togglePlay() { + if (audioPlayer.paused) { + audioPlayer.play(); + playBtn.textContent = '暂停'; + } else { + audioPlayer.pause(); + playBtn.textContent = '播放'; + } +} + +// 音频播放结束 +function onAudioEnded() { + playBtn.textContent = '播放'; +} + +// 下载音频 +function downloadAudio() { + if (!currentAudioURL) { + return; + } + + // 创建下载链接 + const link = document.createElement('a'); + link.href = currentAudioURL; + link.download = 'speech.mp3'; + + // 触发下载 + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// 设置状态 +function setStatus(type, message) { + statusDiv.className = `status ${type}`; + statusDiv.textContent = message; + + // 清除成功状态 + if (type === 'success') { + setTimeout(() => { + statusDiv.className = 'status'; + statusDiv.textContent = ''; + }, 3000); + } +} + +// 禁用控件 +function disableControls() { + generateBtn.disabled = true; + playBtn.disabled = true; + downloadBtn.disabled = true; + generateBtn.classList.add('loading'); +} + +// 启用控件 +function enableControls() { + generateBtn.disabled = false; + playBtn.disabled = false; + downloadBtn.disabled = false; + generateBtn.classList.remove('loading'); +} + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', init); diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..a03bb7a --- /dev/null +++ b/config.example.json @@ -0,0 +1,4 @@ +{ + "api_base_url": "https://api.openai.com", + "port": "8080" +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..aae6241 --- /dev/null +++ b/index.html @@ -0,0 +1,94 @@ + + + + + + OpenAITTS配音台 + + + +
+
+

OpenAITTS配音台

+

简洁高效的文本转语音工具

+
+ +
+ +
+

API配置

+
+
+ + +
+
+ + +
+
+
+ + +
+

输入文本

+
+ +
+ 0 字符 +
+
+
+ + +
+

语音参数

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + +
+
+
+ + +
+

音频播放

+
+ +
+
+
+ + +
+ + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..3117508 --- /dev/null +++ b/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "io" + "log" + "net/http" + "os" + "strings" +) + +// 配置结构体 +type Config struct { + APIBaseURL string `json:"api_base_url"` + Port string `json:"port"` +} + +// 从配置文件加载配置 +func loadConfig() Config { + config := Config{ + APIBaseURL: "https://api.openai.com", // 默认API地址 + Port: "8080", // 默认端口 + } + + // 尝试读取配置文件 + if _, err := os.Stat("config.json"); err == nil { + data, err := os.ReadFile("config.json") + if err == nil { + json.Unmarshal(data, &config) + } + } + + // 环境变量覆盖配置文件 + if envURL := os.Getenv("OPENAI_API_BASE_URL"); envURL != "" { + config.APIBaseURL = envURL + } + if envPort := os.Getenv("PORT"); envPort != "" { + config.Port = envPort + } + + return config +} + +// TTS请求结构体 +type TTSRequest struct { + APIKey string `json:"api_key"` + Text string `json:"text"` + Voice string `json:"voice"` + Model string `json:"model"` + APIBase string `json:"api_base"` +} + +// OpenAI TTS API请求体 +type openAITTSRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` +} + +func main() { + // 命令行参数 + var configPath string + flag.StringVar(&configPath, "config", "config.json", "配置文件路径") + flag.Parse() + + config := loadConfig() + + // 静态文件服务 + http.Handle("/", http.FileServer(http.Dir("."))) + + // TTS API端点 + http.HandleFunc("/api/tts", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 解析请求体 + var req TTSRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // 使用用户提供的API地址,如果没有则使用配置中的 + apiBaseURL := config.APIBaseURL + if req.APIBase != "" { + apiBaseURL = req.APIBase + } + + // 构建OpenAI TTS API请求 + openAIReq := openAITTSRequest{ + Model: req.Model, + Input: req.Text, + Voice: req.Voice, + } + + reqBody, err := json.Marshal(openAIReq) + if err != nil { + http.Error(w, "Failed to marshal request", http.StatusInternalServerError) + return + } + + // 创建HTTP客户端 + client := &http.Client{} + openAIReq, err := http.NewRequest("POST", apiBaseURL+"/v1/audio/speech", bytes.NewBuffer(reqBody)) + if err != nil { + http.Error(w, "Failed to create request", http.StatusInternalServerError) + return + } + + // 设置请求头 + openAIReq.Header.Set("Content-Type", "application/json") + openAIReq.Header.Set("Authorization", "Bearer "+req.APIKey) + + // 发送请求 + resp, err := client.Do(openAIReq) + if err != nil { + http.Error(w, "Failed to call OpenAI API: "+err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + http.Error(w, "OpenAI API error: "+string(errBody), resp.StatusCode) + return + } + + // 设置响应头 + w.Header().Set("Content-Type", "audio/mpeg") + w.Header().Set("Content-Disposition", "attachment; filename=speech.mp3") + + // 将响应体写入客户端 + io.Copy(w, resp.Body) + }) + + log.Printf("Server starting on port %s...", config.Port) + log.Printf("Using API base URL: %s", config.APIBaseURL) + log.Printf("Open http://localhost:%s in your browser", config.Port) + + // 启动服务器 + if err := http.ListenAndServe(":"+config.Port, nil); err != nil { + log.Fatal("Server failed to start: ", err) + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..a59b4f8 --- /dev/null +++ b/styles.css @@ -0,0 +1,344 @@ +/* 基础样式重置 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* 全局样式 */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +/* 容器样式 */ +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; + background: rgba(255, 255, 255, 0.95); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + margin-top: 20px; + margin-bottom: 20px; +} + +/* 头部样式 */ +header { + text-align: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +header h1 { + color: #2d3748; + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 10px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.subtitle { + color: #718096; + font-size: 1.1rem; + font-weight: 400; +} + +/* 主要内容区域 */ +main { + display: flex; + flex-direction: column; + gap: 25px; +} + +/* 各部分通用样式 */ +section { + background: #f8fafc; + padding: 25px; + border-radius: 12px; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +section:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +section h2 { + color: #2d3748; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; +} + +section h2::before { + content: ''; + width: 4px; + height: 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 2px; +} + +/* 配置网格布局 */ +.config-grid, +.params-grid, +.controls-grid { + display: grid; + grid-template-columns: 1fr; + gap: 20px; +} + +@media (min-width: 600px) { + .config-grid, + .params-grid, + .controls-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* 表单组样式 */ +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 500; + color: #4a5568; + font-size: 0.95rem; +} + +/* 表单输入样式 */ +.form-input, +.form-select { + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.3s ease; + background: white; + width: 100%; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-input::placeholder { + color: #a0aec0; +} + +/* 文本输入区域 */ +.text-input-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +#textInput { + padding: 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + resize: vertical; + min-height: 150px; + transition: all 0.3s ease; + background: white; +} + +#textInput:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.text-info { + text-align: right; + color: #718096; + font-size: 0.9rem; +} + +/* 按钮样式 */ +.btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-secondary { + background: #e2e8f0; + color: #4a5568; +} + +.btn-secondary:hover:not(:disabled) { + background: #cbd5e0; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* 控制区域 */ +.controls-section { + display: flex; + flex-direction: column; + gap: 15px; +} + +.status { + padding: 12px 16px; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + text-align: center; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +.status.success { + background: #c6f6d5; + color: #22543d; + border: 1px solid #9ae6b4; +} + +.status.error { + background: #fed7d7; + color: #742a2a; + border: 1px solid #feb2b2; +} + +.status.loading { + background: #ebf8ff; + color: #2a4365; + border: 1px solid #bee3f8; +} + +/* 音频播放区域 */ +.audio-player-container { + display: flex; + justify-content: center; + padding: 20px 0; +} + +.audio-player { + width: 100%; + max-width: 100%; + height: 50px; + background: white; + border-radius: 25px; + border: 2px solid #e2e8f0; + transition: all 0.3s ease; +} + +.audio-player:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* 页脚样式 */ +footer { + text-align: center; + margin-top: 30px; + padding-top: 20px; + border-top: 2px solid #e0e0e0; + color: #718096; + font-size: 0.9rem; +} + +/* 响应式设计 */ +@media (max-width: 600px) { + .container { + margin: 10px; + padding: 15px; + } + + header h1 { + font-size: 2rem; + } + + section { + padding: 20px; + } + + .btn { + padding: 10px 20px; + font-size: 0.95rem; + } +} + +/* 动画效果 */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +section { + animation: fadeIn 0.5s ease-out; +} + +/* 加载动画 */ +.loading::after { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; + margin-left: 10px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +}