diff --git a/AGENTS.md b/AGENTS.md index 20e44ea..9b5f0a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ B 站视频自动字幕 Tampermonkey 脚本开发指南 ## 项目概述 -这是一个单文件的 Tampermonkey 用户脚本,集成音频提取、腾讯云 AI 识别和原生级字幕渲染功能。 +这是一个单文件的 Tampermonkey 用户脚本,集成音频提取、多云AI识别(腾讯云/阿里云)和原生级字幕渲染功能。 ## 核心技术实现 @@ -12,6 +12,8 @@ B 站视频自动字幕 Tampermonkey 脚本开发指南 - **安全存储**:使用 Tampermonkey 的 `GM_setValue` / `GM_getValue` API 将密钥加密存储在浏览器本地。 - **配置验证**:提供 `validate()` 方法检查配置完整性。 - **UI 集成**:提供可视化配置面板,用户无需修改代码。 +- **多服务商支持**:支持腾讯云和阿里云两种AI服务提供商,可在配置面板中切换。 +- **向后兼容**:自动迁移旧版本的腾讯云配置格式。 ### 1. 音频提取 (AudioExtractor) - **DASH 解析**:通过 `window.__playinfo__` 获取高码率 DASH 音频流 URL。 @@ -19,6 +21,8 @@ B 站视频自动字幕 Tampermonkey 脚本开发指南 - **缓存机制**:使用 `CacheManager` (IndexedDB) 存储 Blob 数据,避免重复下载,设置 100MB 自动清理阈值。 ### 2. AI 识别服务 (AISubtitleService) + +#### 腾讯云 ASR (TencentCloudProvider) - **API 对接**:对接腾讯云录音文件识别极速版 (`/asr/flash/v1`)。 - **签名鉴权**: - 内置纯 JavaScript 实现的 HMAC-SHA1 算法 (`HmacSha1`),不依赖 `Web Crypto API`,确保在所有 Tampermonkey 环境下的兼容性。 @@ -34,6 +38,25 @@ B 站视频自动字幕 Tampermonkey 脚本开发指南 - 单词间停顿超过 500ms。 - **输出**:生成时间轴精准、长短适宜的 SRT 格式字幕。 +#### 阿里云 ASR (AlibabaCloudProvider) +- **API 对接**:对接阿里云录音文件识别 RESTful API (NLS Meta服务)。 +- **签名鉴权**: + - 使用相同的 HMAC-SHA1 算法,实现阿里云的 RPC 签名规范。 + - 支持特殊字符的 Percent Encoding。 +- **异步识别流程**: + 1. `_submitTask`: 将音频转换为 base64 格式并提交识别任务。 + 2. `_pollResult`: 轮询获取识别结果(最多60次,间隔2秒)。 + 3. `_jsonToSrt`: 解析结果并转换为 SRT 格式。 +- **智能分段算法**: + - 利用词级时间戳 (`Words` 数组) 进行精细断句。 + - 支持相同的断句逻辑(标点符号、长度限制、停顿检测)。 + - 无词级信息时回退到句子级别输出。 + +#### 服务商切换 +- **动态选择**:通过 `ConfigManager.get().provider` 自动选择对应的提供商。 +- **统一接口**:`AISubtitleService.transcribe()` 方法屏蔽底层差异。 +- **错误提示**:根据当前选择的服务商显示相应的错误信息。 + ### 3. 字幕渲染 (SubtitleRenderer) - **DOM 注入**:自动侦测 B 站播放器容器 (`.bpx-player-video-area`) 并插入字幕层。 - **样式复刻**: @@ -75,9 +98,17 @@ B 站视频自动字幕 Tampermonkey 脚本开发指南 - 实现了字幕缓存机制,避免重复调用 API。 - 字幕数据与音频文件关联存储在 IndexedDB 中。 - **工作流程**: - 1. 首次识别:调用腾讯云 API,识别后自动保存字幕到缓存。 + 1. 首次识别:调用 AI API,识别后自动保存字幕到缓存。 2. 再次加载:直接从缓存读取,无需重新调用 API,节省成本和时间。 - **缓存管理**: - 字幕与音频文件关联存储(同一记录中)。 - UI 显示缓存状态("已缓存音频+字幕"或"已缓存音频")。 - - 按钮文案智能切换("加载字幕 (使用缓存)"或"生成字幕 (腾讯云 AI)")。 \ No newline at end of file + - 按钮文案智能切换("加载字幕 (使用缓存)"或"生成字幕 (AI服务商 AI)")。 + +### ✅ 多云 AI 服务支持(v0.3.0) +- **阿里云 ASR 支持**:新增阿里云录音文件识别接口。 +- **服务商切换**:可在配置面板中选择使用腾讯云或阿里云。 +- **统一体验**:不同服务商使用相同的 UI 和工作流程。 +- **智能提示**:按钮和状态栏自动显示当前使用的服务商名称。 +- **配置独立**:腾讯云和阿里云配置分别存储,切换服务商无需重新配置。 +- **向后兼容**:自动迁移旧版本配置,已有用户无感知升级。 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0fc73d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# 更新日志 + +## v0.3.0 (2025-11-22) + +### 新增功能 +- **阿里云 ASR 支持**:新增阿里云智能语音交互录音文件识别接口 + - 支持异步任务提交和结果轮询 + - 实现阿里云 RPC 签名算法 + - 支持词级时间戳和智能断句 + +- **多云服务切换**:在配置面板中可选择使用腾讯云或阿里云 + - 统一的用户体验和工作流程 + - 独立的配置存储,切换无需重新配置 + - 智能提示当前使用的服务商 + +### 改进 +- **配置管理重构**: + - 支持多服务商配置存储 + - 自动迁移旧版本配置(v0.2.x → v0.3.0) + - 配置窗口每次打开时重新加载最新值 + +- **UI 优化**: + - 配置面板支持服务商切换 + - 按钮文案动态显示当前服务商名称 + - 状态提示更加清晰 + +### 技术细节 +- 新增 `AlibabaCloudProvider` 模块 +- 新增 `getProviderName()` 方法用于获取当前服务商名称 +- 配置结构从扁平化改为层级化(`provider` + `tencent`/`alibaba`) +- 实现了阿里云 Percent Encoding 规范 + +### 兼容性 +- 完全向后兼容 v0.2.x +- 旧版本配置自动迁移,无感知升级 +- 未配置用户首次打开会提示选择服务商 + +--- + +## v0.2.1 + +### 新增功能 +- **字幕缓存**:识别成功后自动保存字幕到 IndexedDB +- 再次访问时优先读取缓存,避免重复调用 API +- UI 显示缓存状态("已缓存音频+字幕"或"已缓存音频") + +--- + +## v0.2.0 + +### 新增功能 +- **可视化配置面板**:无需修改代码即可配置 API 密钥 +- **安全存储**:使用 `GM_setValue` 在浏览器本地存储密钥 +- **首次使用引导**:自动弹出配置窗口 + +--- + +## v0.1.0 + +### 初始版本 +- 音频提取功能 +- 腾讯云 ASR 对接 +- 智能断句算法 +- 字幕渲染 +- IndexedDB 音频缓存 diff --git a/README.md b/README.md index 8202294..462232b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # B 站视频自动字幕脚本 -一个基于 Tampermonkey 的用户脚本,为 B 站视频自动生成字幕。通过提取视频音频、调用 **腾讯云录音识别极速版 API** 进行语音识别,并自动将字幕渲染到视频播放器中。 +一个基于 Tampermonkey 的用户脚本,为 B 站视频自动生成字幕。通过提取视频音频、调用 **AI 语音识别服务**(支持腾讯云/阿里云)进行语音识别,并自动将字幕渲染到视频播放器中。 ## 功能特性 @@ -9,7 +9,9 @@ - **智能缓存**:使用浏览器 IndexedDB 缓存音频文件和字幕数据(上限 100MB),避免重复下载和调用 API,节省流量和成本。 ### 2. AI 极速识别 -- **腾讯云对接**:集成腾讯云录音文件识别极速版 API,识别速度快(通常 30 分钟音频仅需 10 秒)。 +- **多云支持**:支持腾讯云和阿里云两种 AI 服务提供商,可自由切换。 + - **腾讯云**:录音文件识别极速版 API,识别速度快(通常 30 分钟音频仅需 10 秒)。 + - **阿里云**:智能语音交互 ASR 服务,支持词级时间戳和高准确率识别。 - **智能断句**: - 采用 **词级重组算法**,将长语音流拆分为适合阅读的短句。 - 基于**标点符号**、**自然停顿 (>500ms)** 和**字符长度 (20字)** 进行智能切分。 @@ -24,7 +26,9 @@ ### 前置要求 - 安装 [Tampermonkey](https://www.tampermonkey.net/) 浏览器扩展。 -- 注册 [腾讯云账号](https://cloud.tencent.com/) 并开通 [录音文件识别极速版](https://cloud.tencent.com/product/flash-asr)(新用户通常有免费额度)。 +- 选择一个 AI 服务提供商并注册账号(二选一): + - **腾讯云**:注册 [腾讯云账号](https://cloud.tencent.com/) 并开通 [录音文件识别极速版](https://cloud.tencent.com/product/flash-asr)(新用户通常有免费额度)。 + - **阿里云**:注册 [阿里云账号](https://www.aliyun.com/) 并开通 [智能语音交互](https://www.aliyun.com/product/nls)(新用户通常有免费额度)。 ### 安装步骤 1. 安装 Tampermonkey 扩展。 @@ -32,37 +36,57 @@ 3. 保存脚本并刷新 B 站视频页面。 4. **首次使用配置**: - 脚本会自动弹出配置面板。 - - 填入您的腾讯云 `APPID`, `SECRET_ID`, `SECRET_KEY`。 + - 选择服务提供商(腾讯云或阿里云)。 + - 填入相应的 API 密钥(见下方"配置说明")。 - 点击"保存",密钥将安全存储在浏览器本地。 - *注意:密钥仅存储在您的浏览器中,不会上传到任何服务器。* ## 使用说明 1. **打开视频**:访问任意 B 站视频页,右侧会出现"B站自动字幕"悬浮面板。 -2. **配置密钥**(首次使用):点击"⚙️ 设置"按钮,输入腾讯云 API 密钥并保存。 +2. **配置密钥**(首次使用):点击"⚙️ 设置"按钮,选择服务提供商并输入相应 API 密钥,点击保存。 3. **提取音频**:点击"提取音频",脚本会自动下载并缓存音频。 -4. **生成字幕**:下载完成后点击"生成字幕 (腾讯云 AI)",脚本将上传音频并获取字幕。 +4. **生成字幕**:下载完成后点击"生成字幕 (AI服务商名称)",脚本将上传音频并获取字幕。 + - 腾讯云:通常 10-30 秒即可完成识别。 + - 阿里云:采用异步识别,需要等待 30-120 秒(脚本会自动轮询结果)。 5. **观看视频**:字幕生成后会自动加载,随视频播放同步显示。 6. **再次访问**:已识别过的视频会显示"已缓存音频+字幕",点击"加载字幕 (使用缓存)"即可直接加载,无需重新调用 API。 ## 配置说明 -### 腾讯云 API 配置 - 脚本提供了**可视化配置界面**,无需修改代码: +### 通用配置步骤 1. 点击悬浮面板上的"⚙️ 设置"按钮。 -2. 在弹出的配置窗口中填入以下信息: - - **APPID**:腾讯云账号 APPID - - **SECRET_ID**:API 密钥 ID - - **SECRET_KEY**:API 密钥 Key(输入时会隐藏显示) -3. 点击"保存"完成配置。 +2. 在"选择服务提供商"下拉菜单中选择腾讯云或阿里云。 +3. 根据选择填入相应的密钥信息(见下方各服务商说明)。 +4. 点击"保存"完成配置。 + +### 腾讯云 API 配置 + +**需要的信息**: +- **APPID**:腾讯云账号 APPID +- **SECRET_ID**:API 密钥 ID +- **SECRET_KEY**:API 密钥 Key -### 获取密钥 +**获取密钥**: 1. 访问 [腾讯云 API 密钥管理](https://console.cloud.tencent.com/cam/capi)。 2. 新建密钥,获取 `SecretId` 和 `SecretKey`。 3. 访问 [账号信息](https://console.cloud.tencent.com/developer) 获取 `APPID`。 +### 阿里云 API 配置 + +**需要的信息**: +- **ACCESS_KEY_ID**:阿里云访问密钥 ID +- **ACCESS_KEY_SECRET**:阿里云访问密钥 Secret +- **APP_KEY**:智能语音交互项目的 AppKey + +**获取密钥**: +1. 访问 [阿里云 AccessKey 管理](https://ram.console.aliyun.com/manage/ak)。 +2. 创建 AccessKey,获取 `AccessKeyId` 和 `AccessKeySecret`。 +3. 访问 [智能语音交互控制台](https://nls-portal.console.aliyun.com/applist)。 +4. 创建项目,获取项目的 `AppKey`。 + ### 安全性说明 - 密钥通过 Tampermonkey 的 `GM_setValue` API 存储在浏览器本地。 - 数据不会上传到任何第三方服务器。 @@ -72,14 +96,24 @@ - **音频提取**:解析 `window.__playinfo__` 获取 DASH 音频流。 - **缓存层**:`CacheManager` 模块封装 IndexedDB,实现 FIFO 缓存淘汰策略,同时缓存音频文件和字幕数据。 -- **识别层**:`TencentCloudProvider` 实现 HMAC-SHA1 签名鉴权(纯 JS 实现,无外部依赖),直接调用腾讯云 REST API。 +- **识别层**: + - `TencentCloudProvider`:实现腾讯云录音识别极速版 API 对接。 + - `AlibabaCloudProvider`:实现阿里云智能语音交互 API 对接(异步识别+轮询)。 + - 统一的 HMAC-SHA1 签名鉴权(纯 JS 实现,无外部依赖)。 - **字幕缓存**:识别成功后自动保存字幕到 IndexedDB,再次访问时优先读取缓存,避免重复调用 API。 - **解析层**:`SRTParser` 负责解析字幕,`SubtitleRenderer` 负责 DOM 渲染。 +- **配置管理**:`ConfigManager` 支持多服务商配置,自动迁移旧版本数据。 ## 常见问题 -### Q: 为什么提示"签名错误 (4002)"? -A: 请检查脚本中的 `APPID`, `SECRET_ID`, `SECRET_KEY` 是否正确,且没有多余空格。确保您的腾讯云账号已开通语音识别服务。 +### Q: 为什么提示"签名错误"或"API 错误"? +A: 请检查配置的密钥是否正确,且没有多余空格。确保您的云服务账号已开通相应的语音识别服务。 + +### Q: 腾讯云和阿里云有什么区别? +A: +- **腾讯云**:识别速度更快(10-30秒),适合快速生成字幕。 +- **阿里云**:识别准确率更高,支持更多音频格式,但识别时间稍长(30-120秒)。 +- 建议根据您的需求和已有的云服务账号选择。 ### Q: 字幕时间对不上? A: 脚本已内置时间戳修正逻辑。如果仍有偏差,可能是网络延迟或音频文件版本问题,尝试点击"重新生成"或刷新页面。 diff --git a/bilibili-auto-subtitle.user.js b/bilibili-auto-subtitle.user.js index ab721af..2c54099 100644 --- a/bilibili-auto-subtitle.user.js +++ b/bilibili-auto-subtitle.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name B站自动字幕 // @namespace http://tampermonkey.net/ -// @version 0.2.1 -// @description 为B站视频自动生成字幕,支持提取音频、AI识别、字幕缓存和字幕显示 +// @version 0.3.0 +// @description 为B站视频自动生成字幕,支持提取音频、AI识别(腾讯云/阿里云)、字幕缓存和字幕显示 // @author You // @match https://www.bilibili.com/video/* // @icon https://www.bilibili.com/favicon.ico @@ -24,17 +24,39 @@ const CONFIG_KEY = 'bili_subtitle_config'; const DEFAULT_CONFIG = { - APPID: "", - SECRET_ID: "", - SECRET_KEY: "", - ENGINE_TYPE: "16k_zh" + provider: "tencent", // tencent 或 alibaba + tencent: { + APPID: "", + SECRET_ID: "", + SECRET_KEY: "", + ENGINE_TYPE: "16k_zh" + }, + alibaba: { + ACCESS_KEY_ID: "", + ACCESS_KEY_SECRET: "", + APP_KEY: "" + } }; return { get: function() { const saved = GM_getValue(CONFIG_KEY, null); if (saved) { - return JSON.parse(saved); + const config = JSON.parse(saved); + // 迁移旧版本配置 + if (config.APPID && !config.provider) { + return { + provider: "tencent", + tencent: { + APPID: config.APPID, + SECRET_ID: config.SECRET_ID, + SECRET_KEY: config.SECRET_KEY, + ENGINE_TYPE: config.ENGINE_TYPE || "16k_zh" + }, + alibaba: DEFAULT_CONFIG.alibaba + }; + } + return config; } return DEFAULT_CONFIG; }, @@ -45,18 +67,39 @@ isConfigured: function() { const config = this.get(); - return !!(config.APPID && config.SECRET_ID && config.SECRET_KEY); + if (config.provider === 'tencent') { + return !!(config.tencent.APPID && config.tencent.SECRET_ID && config.tencent.SECRET_KEY); + } else if (config.provider === 'alibaba') { + return !!(config.alibaba.ACCESS_KEY_ID && config.alibaba.ACCESS_KEY_SECRET && config.alibaba.APP_KEY); + } + return false; }, validate: function(config) { - if (!config.APPID || !config.APPID.trim()) { - return { valid: false, message: 'APPID 不能为空' }; - } - if (!config.SECRET_ID || !config.SECRET_ID.trim()) { - return { valid: false, message: 'SECRET_ID 不能为空' }; + if (!config.provider) { + return { valid: false, message: '请选择服务提供商' }; } - if (!config.SECRET_KEY || !config.SECRET_KEY.trim()) { - return { valid: false, message: 'SECRET_KEY 不能为空' }; + + if (config.provider === 'tencent') { + if (!config.tencent.APPID || !config.tencent.APPID.trim()) { + return { valid: false, message: '腾讯云 APPID 不能为空' }; + } + if (!config.tencent.SECRET_ID || !config.tencent.SECRET_ID.trim()) { + return { valid: false, message: '腾讯云 SECRET_ID 不能为空' }; + } + if (!config.tencent.SECRET_KEY || !config.tencent.SECRET_KEY.trim()) { + return { valid: false, message: '腾讯云 SECRET_KEY 不能为空' }; + } + } else if (config.provider === 'alibaba') { + if (!config.alibaba.ACCESS_KEY_ID || !config.alibaba.ACCESS_KEY_ID.trim()) { + return { valid: false, message: '阿里云 ACCESS_KEY_ID 不能为空' }; + } + if (!config.alibaba.ACCESS_KEY_SECRET || !config.alibaba.ACCESS_KEY_SECRET.trim()) { + return { valid: false, message: '阿里云 ACCESS_KEY_SECRET 不能为空' }; + } + if (!config.alibaba.APP_KEY || !config.alibaba.APP_KEY.trim()) { + return { valid: false, message: '阿里云 APP_KEY 不能为空' }; + } } return { valid: true }; } @@ -301,7 +344,7 @@ transcribe: async function(audioBlob) { // 从 ConfigManager 读取配置 - const config = ConfigManager.get(); + const config = ConfigManager.get().tencent; const timestamp = Math.floor(Date.now() / 1000); const params = { @@ -328,9 +371,9 @@ const urlPath = `/asr/flash/v1/${config.APPID}`; const signStr = `POST${urlHost}${urlPath}?${queryStr}`; - console.log('[AISubtitleService] 签名原文:', signStr); + console.log('[TencentCloud] 签名原文:', signStr); const signature = HmacSha1(config.SECRET_KEY, signStr); - console.log('[AISubtitleService] 签名结果:', signature); + console.log('[TencentCloud] 签名结果:', signature); const requestUrl = `https://${urlHost}${urlPath}?${queryStr}`; @@ -434,15 +477,278 @@ } }; - let _currentProvider = TencentCloudProvider; + // 阿里云 ASR 提供者 + const AlibabaCloudProvider = { + name: 'alibaba', + + transcribe: async function(audioBlob) { + // 从 ConfigManager 读取配置 + const config = ConfigManager.get().alibaba; + + // 第一步:提交识别任务 + const taskId = await this._submitTask(audioBlob, config); + console.log('[AlibabaCloud] 任务ID:', taskId); + + // 第二步:轮询获取结果 + const result = await this._pollResult(taskId, config); + + // 第三步:转换为 SRT 格式 + return this._jsonToSrt(result); + }, + + _submitTask: async function(audioBlob, config) { + // 将音频转换为 base64 + const base64Audio = await this._blobToBase64(audioBlob); + + const timestamp = new Date().toISOString(); + const nonce = this._generateNonce(); + + const params = { + AccessKeyId: config.ACCESS_KEY_ID, + Action: 'SubmitTask', + Format: 'JSON', + SignatureMethod: 'HMAC-SHA1', + SignatureNonce: nonce, + SignatureVersion: '1.0', + Timestamp: timestamp, + Version: '2019-08-23' + }; + + const body = JSON.stringify({ + app_key: config.APP_KEY, + file_link: base64Audio, + version: '4.0', + enable_words: true + }); + + const signature = this._generateSignature('POST', params, config.ACCESS_KEY_SECRET, body); + params.Signature = signature; + + const queryString = Object.keys(params).map(k => + `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}` + ).join('&'); + + const url = `https://nls-meta.cn-shanghai.aliyuncs.com/?${queryString}`; + + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json' + }, + data: body, + onload: (response) => { + if (response.status === 200) { + try { + const resData = JSON.parse(response.responseText); + if (resData.TaskId) { + resolve(resData.TaskId); + } else { + reject(new Error(`阿里云 API 错误: ${resData.Message || '未知错误'}`)); + } + } catch (e) { + reject(new Error('解析响应失败: ' + e.message)); + } + } else { + reject(new Error(`请求失败: ${response.status} ${response.statusText}`)); + } + }, + onerror: (err) => reject(new Error('网络错误')) + }); + }); + }, + + _pollResult: async function(taskId, config, maxAttempts = 60, interval = 2000) { + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, interval)); + + const timestamp = new Date().toISOString(); + const nonce = this._generateNonce(); + + const params = { + AccessKeyId: config.ACCESS_KEY_ID, + Action: 'GetTaskResult', + Format: 'JSON', + SignatureMethod: 'HMAC-SHA1', + SignatureNonce: nonce, + SignatureVersion: '1.0', + TaskId: taskId, + Timestamp: timestamp, + Version: '2019-08-23' + }; + + const signature = this._generateSignature('GET', params, config.ACCESS_KEY_SECRET); + params.Signature = signature; + + const queryString = Object.keys(params).map(k => + `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}` + ).join('&'); + + const url = `https://nls-meta.cn-shanghai.aliyuncs.com/?${queryString}`; + + try { + const result = await new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'GET', + url: url, + onload: (response) => { + if (response.status === 200) { + resolve(JSON.parse(response.responseText)); + } else { + reject(new Error(`请求失败: ${response.status}`)); + } + }, + onerror: reject + }); + }); + + if (result.StatusText === 'SUCCESS') { + return JSON.parse(result.Result); + } else if (result.StatusText === 'RUNNING' || result.StatusText === 'QUEUEING') { + console.log(`[AlibabaCloud] 识别中... (${i+1}/${maxAttempts})`); + continue; + } else { + throw new Error(`识别失败: ${result.StatusText}`); + } + } catch (e) { + if (i === maxAttempts - 1) throw e; + } + } + throw new Error('识别超时'); + }, + + _generateSignature: function(method, params, secretKey, body = '') { + // 1. 参数排序 + const sortedKeys = Object.keys(params).sort(); + const canonicalizedQueryString = sortedKeys + .map(k => `${this._percentEncode(k)}=${this._percentEncode(params[k])}`) + .join('&'); + + // 2. 构造待签名字符串 + const stringToSign = `${method}&${this._percentEncode('/')}&${this._percentEncode(canonicalizedQueryString)}`; + + // 3. 计算签名 + const signature = HmacSha1(secretKey + '&', stringToSign); + + console.log('[AlibabaCloud] 签名原文:', stringToSign); + console.log('[AlibabaCloud] 签名结果:', signature); + + return signature; + }, + + _percentEncode: function(str) { + return encodeURIComponent(str) + .replace(/\!/g, '%21') + .replace(/\'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); + }, + + _generateNonce: function() { + return Math.random().toString(36).substring(2) + Date.now().toString(36); + }, + + _blobToBase64: function(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = reader.result.split(',')[1]; + resolve(`data:audio/m4a;base64,${base64}`); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }, + + _jsonToSrt: function(result) { + let srt = ''; + let index = 1; + + if (result.Sentences && result.Sentences.length > 0) { + const sentences = result.Sentences; + + for (const sentence of sentences) { + // 使用词级信息进行更精细的断句 + if (sentence.Words && sentence.Words.length > 0) { + let currentSegment = []; + let currentLength = 0; + const MAX_CHARS = 20; + const PUNCTUATION = /[,。!?;:、,.;:?!]/; + + for (let i = 0; i < sentence.Words.length; i++) { + const wordObj = sentence.Words[i]; + const word = wordObj.Word; + + currentSegment.push(wordObj); + currentLength += word.length; + + const isPunctuation = PUNCTUATION.test(word); + const isOverLength = currentLength >= MAX_CHARS; + const isLastWord = i === sentence.Words.length - 1; + let isPause = false; + + if (!isLastWord) { + const nextWord = sentence.Words[i + 1]; + if (nextWord.BeginTime - wordObj.EndTime > 500) { + isPause = true; + } + } + + if (isPunctuation || isOverLength || isPause || isLastWord) { + if (currentSegment.length > 0) { + const startTime = formatTime(currentSegment[0].BeginTime); + const endTime = formatTime(currentSegment[currentSegment.length - 1].EndTime); + const text = currentSegment.map(w => w.Word).join(''); + + srt += `${index}\n${startTime} --> ${endTime}\n${text}\n\n`; + index++; + + currentSegment = []; + currentLength = 0; + } + } + } + } else { + // 没有词级信息,使用句子级别 + const startTime = formatTime(sentence.BeginTime); + const endTime = formatTime(sentence.EndTime); + const text = sentence.Text; + + srt += `${index}\n${startTime} --> ${endTime}\n${text}\n\n`; + index++; + } + } + } + + return srt; + } + }; return { transcribe: async function(audioBlob) { - if (!_currentProvider) throw new Error('未设置 AI 提供者'); + const config = ConfigManager.get(); + + // 选择提供者 + let provider; + if (config.provider === 'alibaba') { + provider = AlibabaCloudProvider; + } else { + provider = TencentCloudProvider; + } + if (!ConfigManager.isConfigured()) { - throw new Error('请先配置腾讯云 API 密钥'); + const providerName = config.provider === 'alibaba' ? '阿里云' : '腾讯云'; + throw new Error(`请先配置${providerName} API 密钥`); } - return _currentProvider.transcribe(audioBlob); + + return provider.transcribe(audioBlob); + }, + + getProviderName: function() { + const config = ConfigManager.get(); + return config.provider === 'alibaba' ? '阿里云' : '腾讯云'; } }; })(); @@ -659,8 +965,9 @@ function _createConfigModal() { if (_configModal) { - _configModal.style.display = 'flex'; - return; + // 如果模态框已存在,移除它并重新创建以更新配置值 + _configModal.remove(); + _configModal = null; } _configModal = document.createElement('div'); @@ -674,79 +981,188 @@ const panel = document.createElement('div'); panel.style.cssText = ` background: white; padding: 25px; border-radius: 12px; - width: 450px; max-width: 90vw; box-shadow: 0 4px 20px rgba(0,0,0,0.3); + width: 500px; max-width: 90vw; max-height: 90vh; overflow-y: auto; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); `; const title = document.createElement('h2'); - title.innerText = '腾讯云 API 配置'; + title.innerText = 'AI 服务配置'; title.style.cssText = 'margin: 0 0 20px 0; font-size: 20px; color: #333;'; panel.appendChild(title); const config = ConfigManager.get(); + // 服务提供商选择 + const providerLabel = document.createElement('label'); + providerLabel.innerText = '选择服务提供商:'; + providerLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666; font-weight: bold;'; + panel.appendChild(providerLabel); + + const providerSelect = document.createElement('select'); + providerSelect.style.cssText = ` + width: 100%; padding: 10px; margin-bottom: 20px; + border: 1px solid #ddd; border-radius: 4px; font-size: 14px; + box-sizing: border-box; cursor: pointer; + `; + providerSelect.innerHTML = ` + + + `; + panel.appendChild(providerSelect); + + // 腾讯云配置容器 + const tencentContainer = document.createElement('div'); + tencentContainer.style.display = config.provider === 'tencent' ? 'block' : 'none'; + + const tencentTitle = document.createElement('h3'); + tencentTitle.innerText = '腾讯云配置'; + tencentTitle.style.cssText = 'margin: 15px 0 10px 0; font-size: 16px; color: #00a1d6;'; + tencentContainer.appendChild(tencentTitle); + // APPID const appidLabel = document.createElement('label'); appidLabel.innerText = 'APPID:'; appidLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666;'; - panel.appendChild(appidLabel); + tencentContainer.appendChild(appidLabel); const appidInput = document.createElement('input'); appidInput.type = 'text'; - appidInput.value = config.APPID || ''; + appidInput.value = config.tencent.APPID || ''; appidInput.placeholder = '请输入腾讯云 APPID'; appidInput.style.cssText = ` width: 100%; padding: 10px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; `; - panel.appendChild(appidInput); + tencentContainer.appendChild(appidInput); // SECRET_ID const sidLabel = document.createElement('label'); sidLabel.innerText = 'SECRET_ID:'; sidLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666;'; - panel.appendChild(sidLabel); + tencentContainer.appendChild(sidLabel); const sidInput = document.createElement('input'); sidInput.type = 'text'; - sidInput.value = config.SECRET_ID || ''; + sidInput.value = config.tencent.SECRET_ID || ''; sidInput.placeholder = '请输入 SECRET_ID'; sidInput.style.cssText = ` width: 100%; padding: 10px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; `; - panel.appendChild(sidInput); + tencentContainer.appendChild(sidInput); // SECRET_KEY const skeyLabel = document.createElement('label'); skeyLabel.innerText = 'SECRET_KEY:'; skeyLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666;'; - panel.appendChild(skeyLabel); + tencentContainer.appendChild(skeyLabel); const skeyInput = document.createElement('input'); skeyInput.type = 'password'; - skeyInput.value = config.SECRET_KEY || ''; + skeyInput.value = config.tencent.SECRET_KEY || ''; skeyInput.placeholder = '请输入 SECRET_KEY'; skeyInput.style.cssText = ` width: 100%; padding: 10px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; `; - panel.appendChild(skeyInput); + tencentContainer.appendChild(skeyInput); + + // 腾讯云提示信息 + const tencentHint = document.createElement('div'); + tencentHint.innerHTML = ` +
+ 💡 获取密钥:腾讯云控制台 +
+ `; + tencentContainer.appendChild(tencentHint); + + panel.appendChild(tencentContainer); - // 提示信息 - const hint = document.createElement('div'); - hint.innerHTML = ` -- 💡 获取密钥:访问 腾讯云控制台 + // 阿里云配置容器 + const alibabaContainer = document.createElement('div'); + alibabaContainer.style.display = config.provider === 'alibaba' ? 'block' : 'none'; + + const alibabaTitle = document.createElement('h3'); + alibabaTitle.innerText = '阿里云配置'; + alibabaTitle.style.cssText = 'margin: 15px 0 10px 0; font-size: 16px; color: #ff6a00;'; + alibabaContainer.appendChild(alibabaTitle); + + // ACCESS_KEY_ID + const akIdLabel = document.createElement('label'); + akIdLabel.innerText = 'ACCESS_KEY_ID:'; + akIdLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666;'; + alibabaContainer.appendChild(akIdLabel); + + const akIdInput = document.createElement('input'); + akIdInput.type = 'text'; + akIdInput.value = config.alibaba.ACCESS_KEY_ID || ''; + akIdInput.placeholder = '请输入 ACCESS_KEY_ID'; + akIdInput.style.cssText = ` + width: 100%; padding: 10px; margin-bottom: 15px; + border: 1px solid #ddd; border-radius: 4px; font-size: 14px; + box-sizing: border-box; + `; + alibabaContainer.appendChild(akIdInput); + + // ACCESS_KEY_SECRET + const akSecretLabel = document.createElement('label'); + akSecretLabel.innerText = 'ACCESS_KEY_SECRET:'; + akSecretLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666;'; + alibabaContainer.appendChild(akSecretLabel); + + const akSecretInput = document.createElement('input'); + akSecretInput.type = 'password'; + akSecretInput.value = config.alibaba.ACCESS_KEY_SECRET || ''; + akSecretInput.placeholder = '请输入 ACCESS_KEY_SECRET'; + akSecretInput.style.cssText = ` + width: 100%; padding: 10px; margin-bottom: 15px; + border: 1px solid #ddd; border-radius: 4px; font-size: 14px; + box-sizing: border-box; + `; + alibabaContainer.appendChild(akSecretInput); + + // APP_KEY + const appKeyLabel = document.createElement('label'); + appKeyLabel.innerText = 'APP_KEY:'; + appKeyLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px; color: #666;'; + alibabaContainer.appendChild(appKeyLabel); + + const appKeyInput = document.createElement('input'); + appKeyInput.type = 'text'; + appKeyInput.value = config.alibaba.APP_KEY || ''; + appKeyInput.placeholder = '请输入 APP_KEY'; + appKeyInput.style.cssText = ` + width: 100%; padding: 10px; margin-bottom: 15px; + border: 1px solid #ddd; border-radius: 4px; font-size: 14px; + box-sizing: border-box; + `; + alibabaContainer.appendChild(appKeyInput); + + // 阿里云提示信息 + const alibabaHint = document.createElement('div'); + alibabaHint.innerHTML = ` +
+ 💡 获取密钥:阿里云AccessKey管理
+ 💡 创建项目:智能语音交互控制台