四零语境后端代码仓库
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.

590 lines
13 KiB

15 hours ago
  1. <template>
  2. <view class="container">
  3. <view class="header">
  4. <text class="title">文字转语音示例</text>
  5. </view>
  6. <view class="form-section">
  7. <!-- 文本输入 -->
  8. <view class="form-item">
  9. <text class="label">输入文本</text>
  10. <textarea
  11. v-model="formData.text"
  12. placeholder="请输入要转换的文本内容"
  13. class="textarea"
  14. maxlength="500"
  15. />
  16. </view>
  17. <!-- 音色选择 -->
  18. <view class="form-item">
  19. <text class="label">音色</text>
  20. <picker
  21. @change="onVoiceTypeChange"
  22. :value="voiceTypeIndex"
  23. :range="voiceTypeList"
  24. range-key="name"
  25. >
  26. <view class="picker">
  27. {{ voiceTypeList[voiceTypeIndex]?.name || '请选择音色' }}
  28. </view>
  29. </picker>
  30. </view>
  31. <!-- 语速调节 -->
  32. <view class="form-item">
  33. <text class="label">语速{{ formData.speed }}</text>
  34. <slider
  35. v-model="formData.speed"
  36. :min="-2"
  37. :max="6"
  38. :step="1"
  39. show-value
  40. class="slider"
  41. />
  42. </view>
  43. <!-- 音量调节 -->
  44. <view class="form-item">
  45. <text class="label">音量{{ formData.volume }}</text>
  46. <slider
  47. v-model="formData.volume"
  48. :min="-10"
  49. :max="10"
  50. :step="1"
  51. show-value
  52. class="slider"
  53. />
  54. </view>
  55. <!-- 音频格式选择 -->
  56. <view class="form-item">
  57. <text class="label">音频格式</text>
  58. <radio-group @change="onCodecChange" class="radio-group">
  59. <label class="radio-item" v-for="codec in codecList" :key="codec.value">
  60. <radio :value="codec.value" :checked="formData.codec === codec.value" />
  61. <text>{{ codec.name }}</text>
  62. </label>
  63. </radio-group>
  64. </view>
  65. </view>
  66. <!-- 操作按钮 -->
  67. <view class="button-section">
  68. <button
  69. @click="loadVoiceTypes"
  70. :disabled="loading"
  71. class="btn btn-secondary"
  72. >
  73. {{ loading ? '加载中...' : '加载音色列表' }}
  74. </button>
  75. <button
  76. @click="convertToVoice"
  77. :disabled="!formData.text || converting"
  78. class="btn btn-primary"
  79. >
  80. {{ converting ? '转换中...' : '开始转换' }}
  81. </button>
  82. <button
  83. @click="playAudio"
  84. :disabled="!audioUrl || playing"
  85. class="btn btn-success"
  86. >
  87. {{ playing ? '播放中...' : '播放音频' }}
  88. </button>
  89. </view>
  90. <!-- 结果显示 -->
  91. <view class="result-section" v-if="audioUrl">
  92. <text class="result-title">转换结果</text>
  93. <view class="audio-info">
  94. <text>音频大小{{ audioSize }}</text>
  95. <text>转换耗时{{ convertTime }}</text>
  96. </view>
  97. </view>
  98. </view>
  99. </template>
  100. <script>
  101. export default {
  102. data() {
  103. return {
  104. // 表单数据
  105. formData: {
  106. text: '你好,这是一个文字转语音的测试。',
  107. speed: 0,
  108. volume: 0,
  109. codec: 'wav'
  110. },
  111. // 音色相关
  112. voiceTypeList: [],
  113. voiceTypeIndex: 0,
  114. // 音频格式选项
  115. codecList: [
  116. { name: 'WAV', value: 'wav' },
  117. { name: 'MP3', value: 'mp3' },
  118. { name: 'PCM', value: 'pcm' }
  119. ],
  120. // 状态控制
  121. loading: false,
  122. converting: false,
  123. playing: false,
  124. // 结果数据
  125. audioUrl: '',
  126. audioSize: '',
  127. convertTime: 0,
  128. // 音频上下文
  129. audioContext: null
  130. }
  131. },
  132. onLoad() {
  133. // 页面加载时自动获取音色列表
  134. this.loadVoiceTypes();
  135. // 获取用户信息(如果需要记录日志)
  136. this.getUserInfo();
  137. },
  138. methods: {
  139. /**
  140. * 获取用户信息
  141. */
  142. getUserInfo() {
  143. // 这里可以从缓存或登录状态获取用户ID
  144. // 示例:从本地存储获取
  145. const userInfo = uni.getStorageSync('userInfo');
  146. if (userInfo && userInfo.id) {
  147. this.userId = userInfo.id;
  148. } else {
  149. // 如果没有用户信息,可以生成一个临时ID
  150. this.userId = 'temp_' + Date.now();
  151. }
  152. },
  153. /**
  154. * 加载音色列表
  155. */
  156. async loadVoiceTypes() {
  157. this.loading = true;
  158. try {
  159. const response = await this.request({
  160. url: '/appletApi/tts/list',
  161. method: 'GET'
  162. });
  163. if (response.success && response.result) {
  164. this.voiceTypeList = response.result;
  165. if (this.voiceTypeList.length > 0) {
  166. this.voiceTypeIndex = 0;
  167. }
  168. uni.showToast({
  169. title: '音色列表加载成功',
  170. icon: 'success'
  171. });
  172. } else {
  173. throw new Error(response.message || '加载音色列表失败');
  174. }
  175. } catch (error) {
  176. console.error('加载音色列表失败:', error);
  177. uni.showToast({
  178. title: '加载音色列表失败',
  179. icon: 'error'
  180. });
  181. } finally {
  182. this.loading = false;
  183. }
  184. },
  185. /**
  186. * 音色选择变化
  187. */
  188. onVoiceTypeChange(e) {
  189. this.voiceTypeIndex = e.detail.value;
  190. },
  191. /**
  192. * 音频格式选择变化
  193. */
  194. onCodecChange(e) {
  195. this.formData.codec = e.detail.value;
  196. },
  197. /**
  198. * 文字转语音
  199. */
  200. async convertToVoice() {
  201. if (!this.formData.text.trim()) {
  202. uni.showToast({
  203. title: '请输入要转换的文本',
  204. icon: 'error'
  205. });
  206. return;
  207. }
  208. this.converting = true;
  209. const startTime = Date.now();
  210. try {
  211. // 构建请求参数
  212. const params = {
  213. text: this.formData.text,
  214. speed: this.formData.speed,
  215. voiceType: this.voiceTypeList[this.voiceTypeIndex]?.id || 0,
  216. volume: this.formData.volume,
  217. codec: this.formData.codec,
  218. };
  219. // 发起请求
  220. const response = await this.requestBinary({
  221. url: '/appletApi/tts/textToVoice',
  222. method: 'GET',
  223. data: params,
  224. responseType: 'arraybuffer'
  225. });
  226. if (response) {
  227. // 计算转换耗时
  228. this.convertTime = ((Date.now() - startTime) / 1000).toFixed(2);
  229. // 创建音频文件
  230. await this.createAudioFile(response, this.formData.codec);
  231. // 计算文件大小
  232. this.audioSize = this.formatFileSize(response.byteLength);
  233. uni.showToast({
  234. title: '转换成功',
  235. icon: 'success'
  236. });
  237. } else {
  238. throw new Error('转换失败,未返回音频数据');
  239. }
  240. } catch (error) {
  241. console.error('文字转语音失败:', error);
  242. uni.showToast({
  243. title: '转换失败: ' + error.message,
  244. icon: 'error'
  245. });
  246. } finally {
  247. this.converting = false;
  248. }
  249. },
  250. /**
  251. * 创建音频文件
  252. */
  253. async createAudioFile(arrayBuffer, codec) {
  254. return new Promise((resolve, reject) => {
  255. // 将ArrayBuffer转换为Base64
  256. const uint8Array = new Uint8Array(arrayBuffer);
  257. let binary = '';
  258. for (let i = 0; i < uint8Array.length; i++) {
  259. binary += String.fromCharCode(uint8Array[i]);
  260. }
  261. const base64 = btoa(binary);
  262. // 创建临时文件
  263. const fileName = `tts_${Date.now()}.${codec}`;
  264. const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`;
  265. // 写入文件
  266. wx.getFileSystemManager().writeFile({
  267. filePath: filePath,
  268. data: arrayBuffer,
  269. success: () => {
  270. this.audioUrl = filePath;
  271. resolve(filePath);
  272. },
  273. fail: (error) => {
  274. console.error('创建音频文件失败:', error);
  275. reject(error);
  276. }
  277. });
  278. });
  279. },
  280. /**
  281. * 播放音频
  282. */
  283. playAudio() {
  284. if (!this.audioUrl) {
  285. uni.showToast({
  286. title: '没有可播放的音频',
  287. icon: 'error'
  288. });
  289. return;
  290. }
  291. this.playing = true;
  292. // 创建音频上下文
  293. if (this.audioContext) {
  294. this.audioContext.destroy();
  295. }
  296. this.audioContext = wx.createInnerAudioContext();
  297. this.audioContext.src = this.audioUrl;
  298. // 监听播放事件
  299. this.audioContext.onPlay(() => {
  300. console.log('开始播放');
  301. });
  302. this.audioContext.onEnded(() => {
  303. console.log('播放结束');
  304. this.playing = false;
  305. });
  306. this.audioContext.onError((error) => {
  307. console.error('播放失败:', error);
  308. this.playing = false;
  309. uni.showToast({
  310. title: '播放失败',
  311. icon: 'error'
  312. });
  313. });
  314. // 开始播放
  315. this.audioContext.play();
  316. },
  317. /**
  318. * 格式化文件大小
  319. */
  320. formatFileSize(bytes) {
  321. if (bytes === 0) return '0 B';
  322. const k = 1024;
  323. const sizes = ['B', 'KB', 'MB', 'GB'];
  324. const i = Math.floor(Math.log(bytes) / Math.log(k));
  325. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  326. },
  327. /**
  328. * 通用请求方法
  329. */
  330. request(options) {
  331. return new Promise((resolve, reject) => {
  332. uni.request({
  333. url: this.getApiUrl(options.url),
  334. method: options.method || 'GET',
  335. data: options.data || {},
  336. header: {
  337. 'Content-Type': 'application/json',
  338. // 如果需要token认证,在这里添加
  339. // 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  340. },
  341. success: (res) => {
  342. if (res.statusCode === 200) {
  343. resolve(res.data);
  344. } else {
  345. reject(new Error(`HTTP ${res.statusCode}: ${res.data?.message || '请求失败'}`));
  346. }
  347. },
  348. fail: (error) => {
  349. reject(error);
  350. }
  351. });
  352. });
  353. },
  354. /**
  355. * 二进制数据请求方法
  356. */
  357. requestBinary(options) {
  358. return new Promise((resolve, reject) => {
  359. uni.request({
  360. url: this.getApiUrl(options.url),
  361. method: options.method || 'GET',
  362. data: options.data || {},
  363. responseType: 'arraybuffer',
  364. header: {
  365. // 如果需要token认证,在这里添加
  366. // 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  367. },
  368. success: (res) => {
  369. if (res.statusCode === 200) {
  370. resolve(res.data);
  371. } else {
  372. reject(new Error(`HTTP ${res.statusCode}: 请求失败`));
  373. }
  374. },
  375. fail: (error) => {
  376. reject(error);
  377. }
  378. });
  379. });
  380. },
  381. /**
  382. * 获取完整的API地址
  383. */
  384. getApiUrl(path) {
  385. // 这里配置你的后端API地址
  386. const baseUrl = 'http://localhost:8080'; // 开发环境
  387. // const baseUrl = 'https://your-domain.com'; // 生产环境
  388. return baseUrl + path;
  389. }
  390. },
  391. onUnload() {
  392. // 页面卸载时销毁音频上下文
  393. if (this.audioContext) {
  394. this.audioContext.destroy();
  395. }
  396. }
  397. }
  398. </script>
  399. <style scoped>
  400. .container {
  401. padding: 20rpx;
  402. background-color: #f5f5f5;
  403. min-height: 100vh;
  404. }
  405. .header {
  406. text-align: center;
  407. margin-bottom: 40rpx;
  408. }
  409. .title {
  410. font-size: 36rpx;
  411. font-weight: bold;
  412. color: #333;
  413. }
  414. .form-section {
  415. background-color: #fff;
  416. border-radius: 16rpx;
  417. padding: 30rpx;
  418. margin-bottom: 30rpx;
  419. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
  420. }
  421. .form-item {
  422. margin-bottom: 30rpx;
  423. }
  424. .form-item:last-child {
  425. margin-bottom: 0;
  426. }
  427. .label {
  428. display: block;
  429. font-size: 28rpx;
  430. color: #333;
  431. margin-bottom: 10rpx;
  432. font-weight: 500;
  433. }
  434. .textarea {
  435. width: 100%;
  436. min-height: 120rpx;
  437. padding: 20rpx;
  438. border: 2rpx solid #e0e0e0;
  439. border-radius: 8rpx;
  440. font-size: 28rpx;
  441. background-color: #fafafa;
  442. }
  443. .picker {
  444. padding: 20rpx;
  445. border: 2rpx solid #e0e0e0;
  446. border-radius: 8rpx;
  447. background-color: #fafafa;
  448. font-size: 28rpx;
  449. }
  450. .slider {
  451. margin-top: 20rpx;
  452. }
  453. .radio-group {
  454. display: flex;
  455. flex-wrap: wrap;
  456. gap: 20rpx;
  457. }
  458. .radio-item {
  459. display: flex;
  460. align-items: center;
  461. gap: 10rpx;
  462. font-size: 28rpx;
  463. }
  464. .button-section {
  465. display: flex;
  466. flex-direction: column;
  467. gap: 20rpx;
  468. margin-bottom: 30rpx;
  469. }
  470. .btn {
  471. padding: 24rpx;
  472. border-radius: 12rpx;
  473. font-size: 30rpx;
  474. font-weight: 500;
  475. border: none;
  476. }
  477. .btn-primary {
  478. background-color: #007aff;
  479. color: #fff;
  480. }
  481. .btn-primary:disabled {
  482. background-color: #ccc;
  483. }
  484. .btn-secondary {
  485. background-color: #6c757d;
  486. color: #fff;
  487. }
  488. .btn-secondary:disabled {
  489. background-color: #ccc;
  490. }
  491. .btn-success {
  492. background-color: #28a745;
  493. color: #fff;
  494. }
  495. .btn-success:disabled {
  496. background-color: #ccc;
  497. }
  498. .result-section {
  499. background-color: #fff;
  500. border-radius: 16rpx;
  501. padding: 30rpx;
  502. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
  503. }
  504. .result-title {
  505. font-size: 32rpx;
  506. font-weight: bold;
  507. color: #333;
  508. margin-bottom: 20rpx;
  509. }
  510. .audio-info {
  511. display: flex;
  512. flex-direction: column;
  513. gap: 10rpx;
  514. }
  515. .audio-info text {
  516. font-size: 28rpx;
  517. color: #666;
  518. }
  519. </style>