对接OpenAI 4O RealTime实现语音实时翻译

打印 上一主题 下一主题

主题 964|帖子 964|积分 2894

资源获取


  • OpenAI官网
  • Azure OpenAI
    我这里用的是第二种,从Azure上获取的模型资源,想要从这获取得先注册Azure,并添加OpenAI资源,而且摆设OpenAI 4O RealTime模型,摆设后可以获得终结点和密钥,雷同如下格式:
终结点
  1. https://openaitest.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview
复制代码
API KEY
  1. abcdefghijklmnopqrstuvwxyz123456789
复制代码
后端实现

参考OpenAI官方文档:https://platform.openai.com/docs/guides/realtime-model-capabilities
OpenAI RealTime不能用Http请求,只能用WebSocket或者WebRTC情势,本文只展示WebSocket方式对接。
本文利用的语言是C#。
调用接口代码示例:
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text;
  5. using System.Threading.Tasks;
  6. using System.Net.WebSockets;
  7. using Newtonsoft.Json.Linq;
  8. using Newtonsoft.Json;
  9. using Azure;
  10. using Microsoft.AspNetCore.Http;
  11. using Microsoft.IdentityModel.Tokens;
  12. namespace RealTime.Modules.Common.Api
  13. {
  14.     public class RealTimeApi
  15.     {
  16.         public static List<RealTimeConnect> RealTimeConnectList = new List<RealTimeConnect>();
  17.         public record RealTimeConnect(string id, ClientWebSocket clinet, string conversationItemID, CancellationTokenSource cts);
  18.         /// <summary>
  19.         /// 创建连接
  20.         /// </summary>
  21.         /// <returns>连接ID,用于下次带session的问答</returns>
  22.         public async Task<string> CreatedConnect(string prompt = "") {
  23.             if (string.IsNullOrEmpty(prompt))
  24.                 prompt = "Your answer can only be a translation of what I said";
  25.             string API_KEY = "YOUR API KEY";
  26.             string ENDPOINT = "wss://YOUR ENDPOINT/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview";
  27.             var ws = new ClientWebSocket();
  28.             ws.Options.SetRequestHeader("api-key", $"{API_KEY}");
  29.             var cts = new CancellationTokenSource();
  30.             await ws.ConnectAsync(new Uri(ENDPOINT), cts.Token);
  31.             string id = Guid.NewGuid().ToString("N");
  32.             await SendAsync(ws, cts, new
  33.             {
  34.                 type = "session.update",
  35.                 session = new
  36.                 {
  37.                     instructions = prompt
  38.                 }
  39.             });
  40.             Console.WriteLine("提示词发送成功");
  41.             await SendAsync(ws, cts, new
  42.             {
  43.                 type = "conversation.item.create",
  44.                 item = new
  45.                 {
  46.                     type = "message",
  47.                     role = "user",
  48.                     content = new[]
  49.                     {
  50.                             new {
  51.                                 type = "input_text",
  52.                                 text = prompt
  53.                             }
  54.                         }
  55.                 }
  56.             });
  57.             Console.WriteLine("初始化问题发送成功");
  58.             var conversationItemID = string.Empty;
  59.             var buffer = new byte[4096];
  60.             while (ws.State == WebSocketState.Open)
  61.             {
  62.                 using (var ms = new MemoryStream())
  63.                 {
  64.                     WebSocketReceiveResult result;
  65.                     do
  66.                     {
  67.                         result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
  68.                         if (result.MessageType == WebSocketMessageType.Close)
  69.                         {
  70.                             await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", cts.Token);
  71.                             break;
  72.                         }
  73.                         ms.Write(buffer, 0, result.Count);
  74.                     }
  75.                     while (!result.EndOfMessage);
  76.                     if (result.MessageType == WebSocketMessageType.Close) break;
  77.                     var fullMessageBytes = ms.ToArray();
  78.                     if (result.MessageType == WebSocketMessageType.Text)
  79.                     {
  80.                         var jsonStr = Encoding.UTF8.GetString(fullMessageBytes);
  81.                         var msg = JObject.Parse(jsonStr);
  82.                         switch (msg["type"].Value<string>())
  83.                         {
  84.                             case "conversation.item.created":
  85.                                 if (msg["type"].Value<string>() == "conversation.item.created")
  86.                                 {
  87.                                     conversationItemID = msg["item"]["id"].Value<string>();
  88.                                     string conversationContent = msg["item"]["content"].Children().First()["text"].Value<string>();
  89.                                     Console.WriteLine($"获取到前置对话ID:{conversationItemID},Content:{conversationContent}");
  90.                                     goto loop_exit;
  91.                                 }
  92.                                 break;
  93.                             default:
  94.                                 break;
  95.                         }
  96.                     }
  97.                 }
  98.             }
  99.         loop_exit:;
  100.             if (string.IsNullOrEmpty(conversationItemID))
  101.                 throw new Exception("前置提示词发送失败");
  102.             RealTimeConnectList.Add(new RealTimeConnect(id, ws, conversationItemID, cts));
  103.             return id;
  104.         }
  105.                
  106.                 /// <summary>
  107.                 /// 问答
  108.                 /// </summary>
  109.                 /// <param name="id">CreatedConnect 所创建的连接ID</param>
  110.                 /// <param name="question">问题或者WAV文件的Base64字符串</param>
  111.                 /// <param name="isAudio">是否输入为音频</param>
  112.                 /// <returns>回答</returns>
  113.                 /// <exception cref="NullReferenceException"></exception>
  114.         public async Task<string> SendQuestion(string id,string question, bool isAudio = true)
  115.         {
  116.             var connect = RealTimeConnectList.FirstOrDefault(x => x.id == id);
  117.             if (connect == null)
  118.                 throw new NullReferenceException("连接不存在");
  119.             var audioChunks = new List<byte>();
  120.             var response = string.Empty;
  121.             var ws = connect.clinet;
  122.             var cts = connect.cts;
  123.             var content = new List<dynamic>() { };
  124.             var input = new List<dynamic>
  125.             {
  126.                 new
  127.                 {
  128.                     type = "item_reference",
  129.                     id = string.IsNullOrEmpty(connect.conversationItemID)?"":connect.conversationItemID
  130.                 },
  131.                 new
  132.                 {
  133.                     type = "message",
  134.                     role = "user",
  135.                     content = content
  136.                 }
  137.             };
  138.             if (isAudio)
  139.             {
  140.                 content.Add(new
  141.                 {
  142.                     type = "input_audio",//当输入问题为音频Base64需要type为input_audio
  143.                     audio = question
  144.                 });
  145.             }
  146.             else
  147.             {
  148.                 content.Add(new
  149.                 {
  150.                     type = "input_text",//当输入问题为真实问题时需要type为input_text
  151.                     text = question
  152.                 });
  153.             }
  154.                         //发送问题
  155.             await SendAsync(ws, cts, new
  156.             {
  157.                 type = "response.create",
  158.                 response = new
  159.                 {
  160.                     conversation = "none",
  161.                     metadata = new { topic = "translate" },
  162.                     modalities = new[] { /*"audio", */"text" },
  163.                     //instructions = question
  164.                     input = input
  165.                 }
  166.             });
  167.             // 接收消息循环
  168.             var buffer = new byte[4096]; // 缓冲区大小可调整
  169.             var conversationItemID = string.Empty;
  170.             while (ws.State == WebSocketState.Open)
  171.             {
  172.                 using (var ms = new MemoryStream())
  173.                 {
  174.                     WebSocketReceiveResult result;
  175.                     do
  176.                     {
  177.                         result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
  178.                         if (result.MessageType == WebSocketMessageType.Close)
  179.                         {
  180.                             await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", cts.Token);
  181.                             break;
  182.                         }
  183.                         ms.Write(buffer, 0, result.Count);
  184.                     }
  185.                     while (!result.EndOfMessage); // 循环接收直到消息结束,当返回数据长度大于4096时,会分批发送
  186.                     if (result.MessageType == WebSocketMessageType.Close) break;
  187.                     var fullMessageBytes = ms.ToArray();
  188.                     if (result.MessageType == WebSocketMessageType.Text)
  189.                     {
  190.                         var jsonStr = Encoding.UTF8.GetString(fullMessageBytes);
  191.                         var msg = JObject.Parse(jsonStr);
  192.                         switch (msg["type"].Value<string>())
  193.                         {
  194.                             case "response.done"://回答结束
  195.                                 {
  196.                                     Console.WriteLine($"提问结束:{question}");
  197.                                     goto loop_exit;
  198.                                 }
  199.                             case "error":
  200.                                 Console.WriteLine($"错误: {msg["error"]}");
  201.                                 goto loop_exit;
  202.                             case "response.audio_transcript.delta":
  203.                                 Console.WriteLine($"识别文本: {msg["delta"]}");
  204.                                 response += msg["delta"].Value<string>();
  205.                                 break;
  206.                             case "response.text.delta":
  207.                                 Console.WriteLine($"识别文本: {msg["delta"]}");
  208.                                 response += msg["delta"].Value<string>();
  209.                                 break;
  210.                             case "response.audio.delta"://当response.create的modalities参数指定audio时,会返回语音回答
  211.                                 var audioData = Convert.FromBase64String(msg["delta"].Value<string>());
  212.                                 Console.WriteLine($"收到音频数据: {audioData.Length}字节");
  213.                                 audioChunks.AddRange(audioData);
  214.                                 break;
  215.                         }
  216.                     }
  217.                 }
  218.             }
  219.             loop_exit:;
  220.             
  221.             // 保存音频文件(当response.create的modalities参数指定audio时,会返回语音回答)
  222.             if (audioChunks.Count > 0)
  223.             {
  224.                 var totalAudio = audioChunks.ToArray();
  225.                 var header = CreateWavHeader(
  226.                     sampleRate: 16000,
  227.                     bitsPerSample: 16,
  228.                     channels: 1,
  229.                     dataSize: totalAudio.Length
  230.                 );
  231.                 File.WriteAllBytes(@"D:\test\output.wav", CombineBytes(header, totalAudio));
  232.                 Console.WriteLine("音频文件已保存为 output.wav");
  233.             }
  234.             else
  235.             {
  236.                 Console.WriteLine("未接收到音频数据");
  237.             }
  238.             Console.WriteLine("回答:" + response);
  239.             return response;
  240.         }
  241.         public async Task CloseConnect(string id)
  242.         {
  243.             var connect = RealTimeConnectList.FirstOrDefault(x => x.id == id);
  244.             if (connect == null)
  245.                 throw new NullReferenceException("连接不存在");
  246.             await connect.clinet.CloseAsync(WebSocketCloseStatus.NormalClosure, "", connect.cts.Token);
  247.         }
  248.                
  249.                 //文件转Base64字符串
  250.         public static string ConvertFileToBase64(string filePath)
  251.         {
  252.             try
  253.             {
  254.                 // 读取文件的所有字节
  255.                 byte[] fileBytes = File.ReadAllBytes(filePath);
  256.                 // 将字节数组转换为Base64字符串
  257.                 string base64String = Convert.ToBase64String(fileBytes);
  258.                 return base64String;
  259.             }
  260.             catch (Exception ex)
  261.             {
  262.                 Console.WriteLine($"转换失败: {ex.Message}");
  263.                 return null;
  264.             }
  265.         }
  266.                 //文件转Base64字符串
  267.         public static async Task<string> ConvertToBase64Async(IFormFile file)
  268.         {
  269.             if (file == null || file.Length == 0)
  270.                 throw new ArgumentException("文件不能为空");
  271.             using (var memoryStream = new MemoryStream())
  272.             {
  273.                 // 将文件内容复制到内存流
  274.                 await file.CopyToAsync(memoryStream);
  275.                 // 获取字节数组并转换为Base64
  276.                 byte[] fileBytes = memoryStream.ToArray();
  277.                 return Convert.ToBase64String(fileBytes);
  278.             }
  279.         }
  280.                 //发送消息
  281.         public async Task SendAsync(ClientWebSocket ws, CancellationTokenSource cts, object obj)
  282.         {
  283.             var json = JsonConvert.SerializeObject(obj);
  284.             await ws.SendAsync(
  285.                    Encoding.UTF8.GetBytes(json),
  286.                    WebSocketMessageType.Text,
  287.                    true,
  288.                    cts.Token);
  289.         }
  290.                
  291.         public static byte[] CombineBytes(params byte[][] arrays)
  292.         {
  293.             var output = new MemoryStream();
  294.             foreach (var arr in arrays)
  295.             {
  296.                 output.Write(arr, 0, arr.Length);
  297.             }
  298.             return output.ToArray();
  299.         }
  300.                
  301.         public static byte[] CreateWavHeader(int sampleRate, int bitsPerSample, int channels, int dataSize)
  302.         {
  303.             using (var ms = new MemoryStream())
  304.             using (var writer = new BinaryWriter(ms))
  305.             {
  306.                 // RIFF 头
  307.                 writer.Write(Encoding.ASCII.GetBytes("RIFF"));
  308.                 writer.Write(dataSize + 36); // 总长度
  309.                 writer.Write(Encoding.ASCII.GetBytes("WAVE"));
  310.                 // fmt 子块
  311.                 writer.Write(Encoding.ASCII.GetBytes("fmt "));
  312.                 writer.Write(16);            // fmt块长度
  313.                 writer.Write((short)1);      // PCM格式
  314.                 writer.Write((short)channels);
  315.                 writer.Write(sampleRate);
  316.                 writer.Write(sampleRate * channels * bitsPerSample / 8); // 字节率
  317.                 writer.Write((short)(channels * bitsPerSample / 8));    // 块对齐
  318.                 writer.Write((short)bitsPerSample);
  319.                 // data 子块
  320.                 writer.Write(Encoding.ASCII.GetBytes("data"));
  321.                 writer.Write(dataSize);
  322.                 return ms.ToArray();
  323.             }
  324.         }
  325.     }
  326. }
复制代码
RealTime Api接收的语音文件可以为wav格式,但当文件的声道数量和比特率不对时,会导致识别有毛病,以是如果传入文件的比特率大于256,必要低落比特率再传入

低落伍

ffmpeg处理音频文件

这里用ffmpeg来处理:
这里只提供c#的处理思路,java python应该更方便吧~
起首上ffmpeg官网https://ffmpeg.org/download.html
找到

进入后找最新的下载就行

解压后是这样的

后面要想摆设到linux docker的话,就把linux的一并下载(摆设在windows iis的可以忽略)


windows我们只用到了bin文件夹的三个exe

linux只用到这两个文件

把这些文件全放入项目里,我这里放在ff/bin 下

转换代码
  1.                 using Xabe.FFmpeg;
  2.         /// <summary>
  3.         /// wav文件比特率转换
  4.         /// </summary>
  5.         /// <param name="filePath">文件路径</param>
  6.         /// <returns></returns>
  7.         public static async Task<(string, string)> ConvertVideoAsync(string filePath)
  8.         {
  9.                 //设置ffmpeg执行文件的目录
  10.             FFmpeg.SetExecutablesPath(@"ff/bin");
  11.             // 自动下载备用方案(没用上,太慢了)
  12.             //await FFmpegDownloader.GetLatestVersion(FFmpegVersion.Official);
  13.                        
  14.             var mediaInfo = await FFmpeg.GetMediaInfo(filePath);
  15.             string saveDirectory = @"UploadFile\Convert";
  16.             if (!Directory.Exists(saveDirectory))
  17.                 Directory.CreateDirectory(saveDirectory);
  18.             string outputFileName = $"{Path.GetFileName(filePath).Replace(Path.GetExtension(filePath), "")}_Convert{Path.GetExtension(filePath)}";
  19.             string outputFilePath = Path.Combine(saveDirectory, outputFileName);
  20.             var conversion = FFmpeg.Conversions.New()
  21.                 .AddStream(mediaInfo.Streams)
  22.                 .AddParameter("-ac 1")
  23.                 .AddParameter("-ar 16000 -acodec pcm_s16le")
  24.                 .AddParameter("-acodec pcm_s16le")
  25.                 .SetOutput(outputFilePath);
  26.             await conversion.Start();
  27.             using (var reader = new BinaryReader(File.OpenRead(outputFilePath)))
  28.             {
  29.                 using (var memoryStream = new MemoryStream())
  30.                 {
  31.                     // 将文件内容复制到内存流
  32.                     await reader.BaseStream.CopyToAsync(memoryStream);
  33.                     // 获取字节数组并转换为Base64
  34.                     byte[] fileBytes = memoryStream.ToArray();
  35.                     return (Convert.ToBase64String(fileBytes), outputFilePath);
  36.                 }
  37.             }
  38.         }
复制代码
调用实例
  1.         RealTimeApi realTimeApi;//依赖注入
  2.     /// <summary>
  3.     /// 问答
  4.     /// </summary>
  5.     /// <param name="formFile">文件</param>
  6.     /// <param name="question">问题</param>
  7.     /// <param name="connectID">连接ID 为空时自动新增</param>
  8.     /// <param name="connectID">提示词</param>
  9.     /// <returns></returns>
  10.     public async Task<List<string>> SendAudioQuestionAsync(IFormFile formFile,string question,string connectID,string prompt)
  11.     {
  12.         try
  13.         {
  14.             if(string.IsNullOrEmpty(connectID))
  15.                 connectID = await realTimeApi.CreatedConnect(prompt);
  16.             List<string> responses = new List<string>();
  17.             if (string.IsNullOrEmpty(question))
  18.             {
  19.                 string filePath = await SaveToLocalAsync(formFile, @"UploadFile\Org");
  20.                 (string base64, string convertFilePath) = await AudioProcessor.ConvertVideoAsync(filePath);
  21.                 File.Delete(convertFilePath);
  22.                 File.Delete(filePath);
  23.                 if (base64 == null)
  24.                     base64 = await RealTimeApi.ConvertToBase64Async(formFile);
  25.                 string response = await realTimeApi.SendQuestion(connectID, base64, true);
  26.                 responses.Add(response);
  27.             }
  28.             else
  29.             {
  30.                 responses.Add(await realTimeApi.SendQuestion(connectID, question, false));
  31.             }
  32.             return responses;
  33.         }
  34.         catch (Exception ex)
  35.         {
  36.             throw;
  37.         }
  38.     }
  39.    
  40.     //保存文件
  41.     public static async Task<string> SaveToLocalAsync(
  42.         IFormFile file,
  43.         string saveDirectory,
  44.         string? customFileName = null)
  45.     {
  46.         // 参数验证
  47.         if (file == null || file.Length == 0)
  48.             throw new ArgumentException("文件不能为空");
  49.         if (string.IsNullOrEmpty(saveDirectory))
  50.             throw new ArgumentException("保存目录不能为空");
  51.         // 创建目录(如果不存在)
  52.         if (!Directory.Exists(saveDirectory))
  53.             Directory.CreateDirectory(saveDirectory);
  54.         Random random = new Random();
  55.         
  56.         string fileName = customFileName ??
  57.             $"{Path.GetFileName(file.FileName).Replace(Path.GetExtension(file.FileName),"")}-{DateTime.Now.ToString("MMdd_HHmmss")}_{random.Next(100,999)}{Path.GetExtension(file.FileName)}";
  58.             
  59.         string filePath = Path.Combine(saveDirectory, fileName);
  60.         
  61.         using (var fileStream = new FileStream(filePath, FileMode.Create))
  62.         {
  63.             await file.CopyToAsync(fileStream);
  64.         }
  65.         return filePath;
  66.     }
复制代码
后续用Controller调用一下就行
前端实现

vite+vue
引入灌音包
  1. npm install recorder-js
复制代码
index.vue
  1. <template>
  2.   <div>
  3.     <AudioRecorder />
  4.   </div>
  5. </template>
  6. <script>
  7. import AudioRecorder from '../components/AudioRecorder.vue'
  8. export default {
  9.   components: {
  10.     AudioRecorder
  11.   }
  12. }
  13. </script>
复制代码
AudioRecorder.vue
  1. <template>
  2.     <div class="audio-recorder">
  3.        <div>
  4.         <p>提示词:</p>
  5.         <textarea v-model="state.prompt" style="width: 306px; height: 213px;"></textarea ></div>
  6.       <button
  7.         @click="toggleRecording"
  8.         :class="{ 'recording': state.isRecording }"
  9.       >
  10.         {{ state.isRecording ? '录音中...' : '开始录音' }}
  11.       </button>
  12.       <p v-if="state.recordingTime">已录制: {{ state.formattedTime }}</p>
  13.       <div v-if="state.error" class="error-message">{{ state.error }}</div>
  14.     </div>
  15.     <div v-for="(item,index) in state.responseMessage">
  16.         <p>{{index+1}}:{{item}}</p>
  17.     </div>
  18.   </template>
  19. <script>
  20. import { reactive, onBeforeUnmount } from 'vue'
  21. import Recorder from 'recorder-js' //需要npm引入
  22. export default {
  23.   name: 'AudioRecorder',
  24.   setup() {
  25.     const state = reactive({
  26.       recorder: null,
  27.       audioContext: null,
  28.       mediaStream: null,
  29.       isRecording: false,
  30.       isProcessing: false,
  31.       error: null,
  32.       startTime: 0,
  33.       responseMessage:[],
  34.       prompt : 'Your answer can only be a translation of what I said.',
  35.     })
  36.     // 初始化音频设备
  37.     const initRecorder = async () => {
  38.       try {
  39.         // 清理旧实例
  40.         if (state.recorder) {
  41.           state.recorder.destroy()
  42.           state.audioContext.close()
  43.         }
  44.         // 创建新实例
  45.         state.audioContext = new (window.AudioContext || window.webkitAudioContext)()
  46.         state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
  47.         
  48.         state.recorder = new Recorder(state.audioContext, {
  49.           numChannels: 1,    // 单声道
  50.         })
  51.         
  52.         await state.recorder.init(state.mediaStream)
  53.       } catch (err) {
  54.         handleError(err)
  55.       }
  56.     }
  57.     // 开始录音
  58.     const startRecording = async () => {
  59.       try {
  60.         if (!state.recorder) {
  61.           await initRecorder()
  62.         }
  63.         
  64.         await state.recorder.start()
  65.         state.isRecording = true
  66.         state.startTime = Date.now()
  67.         state.error = null
  68.       } catch (err) {
  69.         handleError(err)
  70.       }
  71.     }
  72.     // 停止录音(关键修复部分)
  73.     const stopRecording = async () => {
  74.       if (!state.isRecording) return
  75.       
  76.       state.isRecording = false
  77.       try {
  78.         state.isProcessing = true
  79.         
  80.         // 等待录音停止并获取数据
  81.         const { blob, buffer } = await state.recorder.stop()
  82.         
  83.         console.log('获取到音频Blob:', blob)
  84.         console.log('音频Buffer:', buffer)
  85.         
  86.         // 自动下载测试
  87.         //Recorder.download(blob, 'recording')
  88.         // 上传逻辑
  89.         const formData = new FormData()
  90.         formData.append('files', blob, `recording_${Date.now()}.wav`)
  91.         formData.append('IsAudio', true)
  92.         formData.append('prompt', state.prompt)
  93.         await uploadChunk(formData)
  94.         
  95.       } catch (err) {
  96.         handleError(err)
  97.       } finally {
  98.         // 资源清理
  99.         state.mediaStream?.getTracks().forEach(track => track.stop())
  100.         state.audioContext?.close()
  101.         state.recorder = null
  102.         state.isRecording = false
  103.         state.isProcessing = false
  104.       }
  105.     }
  106.     // 文件上传方法
  107.     const uploadChunk = async (formData) => {
  108.       try {
  109.         const response = await fetch('http://localhost:12132/api/RealTime/AudioTranslate', {
  110.           method: 'POST',
  111.           body: formData
  112.         })
  113.         
  114.         if (!response.ok) throw new Error(`上传失败: ${response.status}`)
  115.         console.log(response);
  116.         var jsonRes = await response.json()
  117.         state.connectId = jsonRes.data.connectId;
  118.         const now = new Date();
  119.         var currentTime = now.toLocaleTimeString();
  120.         state.responseMessage.push(currentTime + ':' + jsonRes.data.responses[0]);
  121.         return jsonRes;
  122.       } catch (err) {
  123.         handleError(err)
  124.       }
  125.     }
  126.     // 错误处理
  127.     const handleError = (error) => {
  128.       console.error('录音错误:', error)
  129.       state.error = error.message || '录音功能异常'
  130.       stopRecording()
  131.     }
  132.     // 切换录音状态
  133.     const toggleRecording = () => {
  134.       state.isRecording ? stopRecording() : startRecording()
  135.     }
  136.     // 组件卸载时清理
  137.     onBeforeUnmount(() => {
  138.       if (state.isRecording) stopRecording()
  139.     })
  140.     return {
  141.       state,
  142.       toggleRecording
  143.     }
  144.   }
  145. }
  146. </script>
  147.   <style scoped>
  148.   .audio-recorder {
  149.     max-width: 900px;
  150.     margin: 20px auto;
  151.     padding: 20px;
  152.     border: 1px solid #eee;
  153.     border-radius: 8px;
  154.   }
  155.   
  156.   button {
  157.     padding: 10px 20px;
  158.     background: #42b983;
  159.     color: white;
  160.     border: none;
  161.     border-radius: 4px;
  162.     cursor: pointer;
  163.     transition: background 0.3s;
  164.   }
  165.   
  166.   button:disabled {
  167.     background: #ccc;
  168.     cursor: not-allowed;
  169.   }
  170.   
  171.   button.recording {
  172.     background: #ff4757;
  173.     animation: pulse 1s infinite;
  174.   }
  175.   
  176.   @keyframes pulse {
  177.     0% { opacity: 1; }
  178.     50% { opacity: 0.5; }
  179.     100% { opacity: 1; }
  180.   }
  181.   
  182.   .error-message {
  183.     color: #ff4757;
  184.     margin-top: 10px;
  185.   }
  186.   </style>
复制代码
结果

其实这个模型照旧不太灵敏,必要一个字一个字的说才能正常识别


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

用多少眼泪才能让你相信

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表