四零语境后端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

428 lines
10 KiB

15 hours ago
  1. /**
  2. * UniApp TTS服务类
  3. * 封装文字转语音功能提供简单易用的API
  4. */
  5. import { API_CONFIG, TTS_CONFIG, UTILS, ERROR_CODES, ERROR_MESSAGES } from './uniapp-tts-config.js';
  6. class TTSService {
  7. constructor() {
  8. this.audioContext = null;
  9. this.isPlaying = false;
  10. this.isConverting = false;
  11. this.voiceList = [];
  12. this.currentAudioUrl = '';
  13. }
  14. /**
  15. * 初始化TTS服务
  16. * @param {Object} options 配置选项
  17. */
  18. async init(options = {}) {
  19. try {
  20. // 加载音色列表
  21. await this.loadVoiceList();
  22. // 初始化音频上下文
  23. this.initAudioContext();
  24. console.log('TTS服务初始化成功');
  25. return { success: true };
  26. } catch (error) {
  27. console.error('TTS服务初始化失败:', error);
  28. return { success: false, error: error.message };
  29. }
  30. }
  31. /**
  32. * 加载音色列表
  33. */
  34. async loadVoiceList() {
  35. try {
  36. // 先尝试从缓存获取
  37. const cached = this.getCachedVoiceList();
  38. if (cached) {
  39. this.voiceList = cached;
  40. return cached;
  41. }
  42. // 从服务器获取
  43. const response = await this.request({
  44. url: API_CONFIG.ENDPOINTS.VOICE_LIST,
  45. method: 'GET'
  46. });
  47. if (response.success && response.result) {
  48. this.voiceList = response.result;
  49. this.cacheVoiceList(this.voiceList);
  50. return this.voiceList;
  51. } else {
  52. throw new Error(response.message || '获取音色列表失败');
  53. }
  54. } catch (error) {
  55. console.error('加载音色列表失败:', error);
  56. throw error;
  57. }
  58. }
  59. /**
  60. * 文字转语音
  61. * @param {Object} params 转换参数
  62. * @param {string} params.text 文本内容
  63. * @param {number} params.speed 语速
  64. * @param {number} params.voiceType 音色ID
  65. * @param {number} params.volume 音量
  66. * @param {string} params.codec 音频格式
  67. * @param {string} params.userId 用户ID
  68. */
  69. async textToVoice(params) {
  70. if (this.isConverting) {
  71. throw new Error('正在转换中,请稍候...');
  72. }
  73. // 参数验证
  74. const validation = this.validateParams(params);
  75. if (!validation.valid) {
  76. throw new Error(validation.message);
  77. }
  78. this.isConverting = true;
  79. const startTime = Date.now();
  80. try {
  81. // 构建请求参数
  82. const requestParams = {
  83. text: params.text,
  84. speed: params.speed || TTS_CONFIG.SPEED.DEFAULT,
  85. voiceType: params.voiceType || 0,
  86. volume: params.volume || TTS_CONFIG.VOLUME.DEFAULT,
  87. codec: params.codec || TTS_CONFIG.CODEC.DEFAULT,
  88. userId: params.userId || this.generateUserId()
  89. };
  90. // 发起请求
  91. const audioData = await this.requestBinary({
  92. url: API_CONFIG.ENDPOINTS.TEXT_TO_VOICE,
  93. method: 'GET',
  94. data: requestParams
  95. });
  96. if (!audioData || audioData.byteLength === 0) {
  97. throw new Error('转换失败,未返回音频数据');
  98. }
  99. // 创建音频文件
  100. const audioUrl = await this.createAudioFile(audioData, requestParams.codec);
  101. // 计算转换耗时
  102. const convertTime = ((Date.now() - startTime) / 1000).toFixed(2);
  103. // 更新当前音频URL
  104. this.currentAudioUrl = audioUrl;
  105. return {
  106. success: true,
  107. audioUrl: audioUrl,
  108. audioSize: UTILS.formatFileSize(audioData.byteLength),
  109. convertTime: convertTime,
  110. params: requestParams
  111. };
  112. } catch (error) {
  113. console.error('文字转语音失败:', error);
  114. throw error;
  115. } finally {
  116. this.isConverting = false;
  117. }
  118. }
  119. /**
  120. * 播放音频
  121. * @param {string} audioUrl 音频文件路径可选默认使用最后转换的音频
  122. */
  123. async playAudio(audioUrl) {
  124. const targetUrl = audioUrl || this.currentAudioUrl;
  125. if (!targetUrl) {
  126. throw new Error('没有可播放的音频文件');
  127. }
  128. if (this.isPlaying) {
  129. this.stopAudio();
  130. }
  131. return new Promise((resolve, reject) => {
  132. try {
  133. this.initAudioContext();
  134. this.audioContext.src = targetUrl;
  135. this.isPlaying = true;
  136. this.audioContext.onPlay(() => {
  137. console.log('音频开始播放');
  138. resolve({ success: true, action: 'play_started' });
  139. });
  140. this.audioContext.onEnded(() => {
  141. console.log('音频播放结束');
  142. this.isPlaying = false;
  143. });
  144. this.audioContext.onError((error) => {
  145. console.error('音频播放失败:', error);
  146. this.isPlaying = false;
  147. reject(new Error('音频播放失败'));
  148. });
  149. this.audioContext.play();
  150. } catch (error) {
  151. this.isPlaying = false;
  152. reject(error);
  153. }
  154. });
  155. }
  156. /**
  157. * 停止音频播放
  158. */
  159. stopAudio() {
  160. if (this.audioContext && this.isPlaying) {
  161. this.audioContext.stop();
  162. this.isPlaying = false;
  163. console.log('音频播放已停止');
  164. }
  165. }
  166. /**
  167. * 暂停音频播放
  168. */
  169. pauseAudio() {
  170. if (this.audioContext && this.isPlaying) {
  171. this.audioContext.pause();
  172. console.log('音频播放已暂停');
  173. }
  174. }
  175. /**
  176. * 获取音色列表
  177. */
  178. getVoiceList() {
  179. return this.voiceList;
  180. }
  181. /**
  182. * 根据ID获取音色信息
  183. * @param {number} voiceId 音色ID
  184. */
  185. getVoiceById(voiceId) {
  186. return this.voiceList.find(voice => voice.id === voiceId);
  187. }
  188. /**
  189. * 获取当前播放状态
  190. */
  191. getPlayStatus() {
  192. return {
  193. isPlaying: this.isPlaying,
  194. isConverting: this.isConverting,
  195. currentAudioUrl: this.currentAudioUrl
  196. };
  197. }
  198. /**
  199. * 清理资源
  200. */
  201. destroy() {
  202. if (this.audioContext) {
  203. this.audioContext.destroy();
  204. this.audioContext = null;
  205. }
  206. this.isPlaying = false;
  207. this.isConverting = false;
  208. this.currentAudioUrl = '';
  209. console.log('TTS服务已销毁');
  210. }
  211. // ==================== 私有方法 ====================
  212. /**
  213. * 初始化音频上下文
  214. */
  215. initAudioContext() {
  216. if (this.audioContext) {
  217. this.audioContext.destroy();
  218. }
  219. // #ifdef MP-WEIXIN
  220. this.audioContext = wx.createInnerAudioContext();
  221. // #endif
  222. // #ifdef H5
  223. this.audioContext = uni.createInnerAudioContext();
  224. // #endif
  225. }
  226. /**
  227. * 参数验证
  228. */
  229. validateParams(params) {
  230. if (!params || typeof params !== 'object') {
  231. return { valid: false, message: '参数格式错误' };
  232. }
  233. // 验证文本
  234. const textValidation = UTILS.validateText(params.text);
  235. if (!textValidation.valid) {
  236. return textValidation;
  237. }
  238. // 验证语速
  239. if (params.speed !== undefined) {
  240. const speedValidation = UTILS.validateSpeed(params.speed);
  241. if (!speedValidation.valid) {
  242. return speedValidation;
  243. }
  244. }
  245. // 验证音量
  246. if (params.volume !== undefined) {
  247. const volumeValidation = UTILS.validateVolume(params.volume);
  248. if (!volumeValidation.valid) {
  249. return volumeValidation;
  250. }
  251. }
  252. return { valid: true };
  253. }
  254. /**
  255. * 创建音频文件
  256. */
  257. async createAudioFile(arrayBuffer, codec) {
  258. return new Promise((resolve, reject) => {
  259. const fileName = `tts_${Date.now()}.${codec}`;
  260. // #ifdef MP-WEIXIN
  261. const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`;
  262. wx.getFileSystemManager().writeFile({
  263. filePath: filePath,
  264. data: arrayBuffer,
  265. success: () => resolve(filePath),
  266. fail: (error) => reject(new Error('创建音频文件失败: ' + error.errMsg))
  267. });
  268. // #endif
  269. // #ifdef H5
  270. // H5环境下创建Blob URL
  271. const blob = new Blob([arrayBuffer], { type: `audio/${codec}` });
  272. const url = URL.createObjectURL(blob);
  273. resolve(url);
  274. // #endif
  275. });
  276. }
  277. /**
  278. * 生成用户ID
  279. */
  280. generateUserId() {
  281. // 尝试从存储获取用户ID
  282. let userId = uni.getStorageSync('tts_user_id');
  283. if (!userId) {
  284. userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
  285. uni.setStorageSync('tts_user_id', userId);
  286. }
  287. return userId;
  288. }
  289. /**
  290. * 缓存音色列表
  291. */
  292. cacheVoiceList(voiceList) {
  293. const cacheData = {
  294. data: voiceList,
  295. timestamp: Date.now()
  296. };
  297. uni.setStorageSync('tts_voice_cache', cacheData);
  298. }
  299. /**
  300. * 获取缓存的音色列表
  301. */
  302. getCachedVoiceList() {
  303. try {
  304. const cached = uni.getStorageSync('tts_voice_cache');
  305. if (cached && cached.data) {
  306. // 检查缓存是否过期(24小时)
  307. const expireTime = 24 * 60 * 60 * 1000;
  308. if (Date.now() - cached.timestamp < expireTime) {
  309. return cached.data;
  310. }
  311. }
  312. } catch (error) {
  313. console.error('获取缓存失败:', error);
  314. }
  315. return null;
  316. }
  317. /**
  318. * 通用请求方法
  319. */
  320. request(options) {
  321. return new Promise((resolve, reject) => {
  322. uni.request({
  323. url: UTILS.buildApiUrl(options.url),
  324. method: options.method || 'GET',
  325. data: options.data || {},
  326. timeout: API_CONFIG.TIMEOUT,
  327. header: {
  328. 'Content-Type': 'application/json',
  329. // 如果需要token认证,在这里添加
  330. // 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  331. },
  332. success: (res) => {
  333. if (res.statusCode === 200) {
  334. resolve(res.data);
  335. } else {
  336. reject(new Error(`HTTP ${res.statusCode}: ${res.data?.message || '请求失败'}`));
  337. }
  338. },
  339. fail: (error) => {
  340. reject(new Error(ERROR_MESSAGES[ERROR_CODES.NETWORK_ERROR] || error.errMsg));
  341. }
  342. });
  343. });
  344. }
  345. /**
  346. * 二进制数据请求方法
  347. */
  348. requestBinary(options) {
  349. return new Promise((resolve, reject) => {
  350. uni.request({
  351. url: UTILS.buildApiUrl(options.url),
  352. method: options.method || 'GET',
  353. data: options.data || {},
  354. responseType: 'arraybuffer',
  355. timeout: API_CONFIG.TIMEOUT,
  356. header: {
  357. // 如果需要token认证,在这里添加
  358. // 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  359. },
  360. success: (res) => {
  361. if (res.statusCode === 200) {
  362. resolve(res.data);
  363. } else {
  364. reject(new Error(`HTTP ${res.statusCode}: 请求失败`));
  365. }
  366. },
  367. fail: (error) => {
  368. reject(new Error(ERROR_MESSAGES[ERROR_CODES.NETWORK_ERROR] || error.errMsg));
  369. }
  370. });
  371. });
  372. }
  373. }
  374. // 创建单例实例
  375. const ttsService = new TTSService();
  376. // 导出服务实例和类
  377. export { TTSService, ttsService };
  378. export default ttsService;