UnityHDRP写实数字人全流程基础5—语音输入和语音识别

技术交流群在文章底部

文章目录:

UnityHDRP写实数字人全流程基础课---前言

UnityHDRP写实数字人全流程基础课0---前期准备

UnityHDRP写实数字人全流程基础课1---创建AIAgent

UnityHDRP写实数字人全流程基础课2---AIAgent连接和文本输出

UnityHDRP写实数字人全流程基础课3---查看智能体配置

UnityHDRP写实数字人全流程基础课4---语音输出

UnityHDRP写实数字人全流程基础课5---语音输入和语音识别

UnityHDRP写实数字人全流程基础课6---UI自适应与美化

UnityHDRP写实数字人全流程基础课7---角色导入和渲染

UnityHDRP写实数字人全流程基础课8---动画驱动和眨眼

UnityHDRP写实数字人全流程基础课9---口型驱动

UnityHDRP写实数字人全流程基础课---企业级应用篇内容预告

工程用到的插件资源合集:

通过网盘分享的文件:数字人插件及资源
链接: https://pan.baidu.com/s/16YQlcX0CPe5voNKTowAZxQ?pwd=NWWL 提取码: NWWL
--来自百度网盘超级会员v8的分享

在语音输出连通后,我们的项目拥有了说的功能,作为项目核心的"对话",它要完成的功能肯定不能只有说,它还得听。

那么我们这节课需要完成的就有

  1. 创建一个声音录制的工具
  2. 创建一个button来使用这个录制工具
  3. 将录制的声音交给智能体去理解
  4. 将智能体理解的结果再发送回智能体
    接下来我们开始

1. 创建一个声音录制的工具

在开始创建声音录制工具前我们要明确一下这个工具的核心功能(麦克风录音开始→麦克风录音结束→录音文件保存)

那我们首先要定义麦克风设备这个关键的交互硬件。

csharp 复制代码
[Header("录音设置")]
public string deviceName = ""; // 麦克风设备名称
public int sampleRate = 44100; // 采样率
public int channels = 1; // 声道数

然后在开始时获取默认麦克风的名称,用于后续调用Microphone.Start时使用

csharp 复制代码
  private void Start()
    {        
        // 获取默认麦克风设备名称
        if (string.IsNullOrEmpty(deviceName) && Microphone.devices.Length > 0)
        {
            deviceName = Microphone.devices[0];
            Debug.Log($"使用默认麦克风设备: {deviceName}");
        }
    }

在定义开始录制和结束录制方法前还要定义麦克风的使用状态

csharp 复制代码
private bool isRecording = false; // 是否正在录音

接下来我们定义麦克风开始录制的方法

csharp 复制代码
    /// <summary>
    /// 开始录音
    /// </summary>
    public void StartRecording()
    {
        if (isRecording)
        {
            Debug.LogWarning("已经在录音中");
            return;
        }
        
        if (string.IsNullOrEmpty(deviceName))
        {
            Debug.LogError("未找到麦克风设备");
            return;
        }
        Debug.Log("开始录音...");
        // 开始录音
        recordingClip = Microphone.Start(deviceName, false, 60, sampleRate); // 最大录音时长60秒
        isRecording = true;
    }

然后是麦克风结束录制的方法

csharp 复制代码
/// <summary>
    /// 停止录音
    /// </summary>
    public void StopRecordingAndTranscribe()
    {
        if (!isRecording)
        {
            Debug.LogWarning("没有在录音中");
            return;
        }
        
        Debug.Log("停止录音...");
        
        // 停止录音
        int position = Microphone.GetPosition(deviceName);
        Microphone.End(deviceName);
        isRecording = false;
    }

当我们录制结束时,需要将音频临时保存下来

首先我们要定义一个将AudioClip保存为音频文件的方法,这里选定的是Wav格式,转码这部分可以先不做理解,像套公式一样去用它。

csharp 复制代码
    /// <summary>
    /// 编码并保存WAV文件
    /// </summary>
    private IEnumerator EncodeAndSaveWav(AudioClip clip, string filePath)
    {
        // 获取音频数据
        float[] samples = new float[clip.samples * clip.channels];
        clip.GetData(samples, 0);
        
        // 转换为16位PCM数据
        short[] pcmData = new short[samples.Length];
        for (int i = 0; i < samples.Length; i++)
        {
            pcmData[i] = (short)(samples[i] * 32767);
        }
        
        // 写入WAV文件
        using (FileStream fileStream = new FileStream(filePath, FileMode.Create))
        using (BinaryWriter writer = new BinaryWriter(fileStream))
        {
            // RIFF头
            writer.Write(new char[] { 'R', 'I', 'F', 'F' });
            writer.Write(36 + pcmData.Length * 2);
            writer.Write(new char[] { 'W', 'A', 'V', 'E' });
            
            // fmt块
            writer.Write(new char[] { 'f', 'm', 't', ' ' });
            writer.Write(16);
            writer.Write((short)1); // PCM格式
            writer.Write((short)clip.channels);
            writer.Write(clip.frequency);
            writer.Write(clip.frequency * clip.channels * 2); // 字节率
            writer.Write((short)(clip.channels * 2)); // 块对齐
            writer.Write((short)16); // 位深度
            
            // data块
            writer.Write(new char[] { 'd', 'a', 't', 'a' });
            writer.Write(pcmData.Length * 2);
            
            // 写入PCM数据
            for (int i = 0; i < pcmData.Length; i++)
            {
                writer.Write(pcmData[i]);
            }
        }
        
        yield return null;
    }

然后我们定义一个临时保存音频文件的方法

csharp 复制代码
  /// <summary>
    /// 保存AudioClip为WAV文件
    /// </summary>
    private IEnumerator SaveAudioClipAsWav(AudioClip clip, string filePath, System.Action<string> callback)
    {
        // 确保目录存在
        string directory = Path.GetDirectoryName(filePath);
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
        
        // 转换为WAV格式并保存
        yield return StartCoroutine(EncodeAndSaveWav(clip, filePath));
        
        if (File.Exists(filePath))
        {
            Debug.Log($"音频文件保存成功: {filePath}");
            callback?.Invoke(filePath);
        }
        else
        {
            Debug.LogError("音频文件保存失败");
            callback?.Invoke(null);
        }
    }

最后我们在录音结束时调用这个临时保存音频文件的方法

csharp 复制代码
    public void StopRecordingAndTranscribe()
    {
......前面的代码保持不变        
        // 裁剪音频片段到实际录制长度
        AudioClip trimmedClip = AudioClip.Create("TrimmedRecording", position, channels, sampleRate, false);
        float[] data = new float[position];
        recordingClip.GetData(data, 0);
        trimmedClip.SetData(data, 0);
        
        // 保存音频为WAV文件
        StartCoroutine(SaveAudioClipAsWav(trimmedClip, tempFilePath, (savedFilePath));
    }

2. 创建一个button来使用这个录制工具

在页面上创建一个Button,命名为RecordBtn

UIManager中定义这个button:

csharp 复制代码
public Button recordButton; // 录音按钮

为录音按钮添加事件监听器,当按下button时开始录音,抬起时结束录音

csharp 复制代码
// 添加录音按钮事件监听器
    if (recordButton != null && audioRecorder != null)
    {
        // 获取或添加EventTrigger组件
        EventTrigger eventTrigger = recordButton.GetComponent<EventTrigger>();
        if (eventTrigger == null)
        {
            eventTrigger = recordButton.gameObject.AddComponent<EventTrigger>();
        }
        
        // 清除现有的事件
        eventTrigger.triggers.Clear();
        
        // 添加PointerDown事件
        EventTrigger.Entry pointerDownEntry = new EventTrigger.Entry();
        pointerDownEntry.eventID = EventTriggerType.PointerDown;
        pointerDownEntry.callback.AddListener((data) => { audioRecorder.StartRecording(); });
        eventTrigger.triggers.Add(pointerDownEntry);
        
        // 添加PointerUp事件
        EventTrigger.Entry pointerUpEntry = new EventTrigger.Entry();
        pointerUpEntry.eventID = EventTriggerType.PointerUp;
        pointerUpEntry.callback.AddListener((data) => { audioRecorder.StopRecordingAndTranscribe(); });
        eventTrigger.triggers.Add(pointerUpEntry);
    }
/// <summary>
/// 实现IPointerDownHandler接口
/// </summary>
public void OnPointerDown(PointerEventData eventData)
{
    // 这个方法主要是为了满足接口要求,实际的按钮事件通过EventTrigger处理
}

/// <summary>
/// 实现IPointerUpHandler接口
/// </summary>
public void OnPointerUp(PointerEventData eventData)
{
    // 这个方法主要是为了满足接口要求,实际的按钮事件通过EvenTrigger处理
    }

同时我们需要在UIManager的继承里加上IPointerDownHandler,和IPointerUpHandler

csharp 复制代码
public class UIManager : MonoBehaviour, IPointerDownHandler, IPointerUpHandler

最后我们对按钮是否被按下加入一个明确的标识

csharp 复制代码
/// <summary>
/// 更新录音按钮状态
/// </summary>
/// <param name="isRecording">是否正在录音</param>
public void UpdateRecordButtonState(bool isRecording)
{
    if (recordButton != null)
    {
        ColorBlock colors = recordButton.colors;
        if (isRecording)
        {
           recordButton.GetComponentInChildren<Text>().text = "松开识别";
        }
        else
        {
            recordButton.GetComponentInChildren<Text>().text = "按住说话";
        }
        recordButton.colors = colors;
    }
}

将其绑定在按钮的事件上

csharp 复制代码
 // 添加PointerDown事件
            EventTrigger.Entry pointerDownEntry = new EventTrigger.Entry();
            pointerDownEntry.eventID = EventTriggerType.PointerDown;
            pointerDownEntry.callback.AddListener((data) => { audioRecorder.StartRecording(); });
            pointerDownEntry.callback.AddListener((data) => { UpdateRecordButtonState(true); });
            eventTrigger.triggers.Add(pointerDownEntry);
            
            // 添加PointerUp事件
            EventTrigger.Entry pointerUpEntry = new EventTrigger.Entry();
            pointerUpEntry.eventID = EventTriggerType.PointerUp;
            pointerUpEntry.callback.AddListener((data) => { audioRecorder.StopRecordingAndTranscribe(); });
            pointerUpEntry.callback.AddListener((data) => { UpdateRecordButtonState(false); });
            eventTrigger.triggers.Add(pointerUpEntry);

这样我们就得到一个按下录音,松手发送的录音按钮了

3. 将录制的声音交给智能体去理解

语音识别 - 文档 - 扣子

跟之前一样,我们看到文档中需要构建的参数有:Token、端点和File。

在File中我们挑选了Wav作为我们构建文件的格式(上文中已经构建好了)

那么我们需要构建的就是如下参数

csharp 复制代码
private string token;
private string apiUrl = "https://api.coze.cn/v1/audio/transcriptions";

Token我们从之前构建的智能体配置类CozeBotConfigService中获取

csharp 复制代码
 private CozeBotConfigService configService;

    private void Start()
    {
        // 从CozeBotConfigService获取token
        configService = FindObjectOfType<CozeBotConfigService>();
        token = configService.token;
    }

然后我们构建发送参数的协程

csharp 复制代码
/// <summary>
    /// 发送音频文件进行转录的协程
    /// </summary>
    private IEnumerator TranscribeAudioCoroutine(string audioFilePath, System.Action<string> callback)
    {
        if (string.IsNullOrEmpty(audioFilePath))
        {
            Debug.LogError("音频文件路径为空");
            callback?.Invoke(null);
            yield break;
        }

        if (!File.Exists(audioFilePath))
        {
            Debug.LogError($"音频文件不存在: {audioFilePath}");
            callback?.Invoke(null);
            yield break;
        }

        if (string.IsNullOrEmpty(token))
        {
            Debug.LogError("Token为空");
            callback?.Invoke(null);
            yield break;
        }
        // 创建表单数据
        WWWForm form = new WWWForm();
        // 读取音频文件
        byte[] audioData = File.ReadAllBytes(audioFilePath);
        // 添加文件到表单
        form.AddBinaryData("file", audioData, Path.GetFileName(audioFilePath), "audio/mpeg");

        // 创建UnityWebRequest
        using (UnityWebRequest request = UnityWebRequest.Post(apiUrl, form))
        {
            // 设置Authorization头
            request.SetRequestHeader("Authorization", "Bearer " + token);

            // 发送请求
            yield return request.SendWebRequest();

            // 处理响应
            if (request.result == UnityWebRequest.Result.Success)
            {
                Debug.Log("转录请求成功: " + request.downloadHandler.text);
                
                // 解析响应
                string transcriptionText = ParseTranscriptionResponse(request.downloadHandler.text);
                callback?.Invoke(transcriptionText);
            }
            else
            {
                Debug.LogError($"转录请求失败: {request.error}");
                Debug.LogError($"响应内容: {request.downloadHandler.text}");
                callback?.Invoke(null);
            }
        }
    }

从文档中我们可以知道返回的结构体是

由此构建下面的解析方法

csharp 复制代码
/// <summary>
    /// 解析转录响应
    /// </summary>
    /// <param name="responseText">响应文本</param>
    /// <returns>转录文本</returns>
    private string ParseTranscriptionResponse(string responseText)
    {
        try
        {
            JObject jsonObject = JObject.Parse(responseText);
            
            // 检查code是否为0
            if (jsonObject["code"] != null && jsonObject["code"].ToString() == "0")
            {
                // 提取data中的text字段
                if (jsonObject["data"] != null && jsonObject["data"]["text"] != null)
                {
                    return jsonObject["data"]["text"].ToString();
                }
                else
                {
                    Debug.LogError("响应中缺少data.text字段");
                }
            }
            else
            {
                string errorMsg = jsonObject["msg"]?.ToString() ?? "未知错误";
                Debug.LogError($"转录失败: {errorMsg}");
            }
        }
        catch (System.Exception ex)
        {
            Debug.LogError($"解析响应失败: {ex.Message}");
        }

        return null;
    }

再定义一个公共方法给AudioRecorder调用

csharp 复制代码
/// <summary>
/// 发送音频文件进行转录
/// </summary>
/// <param name="audioFilePath">音频文件路径</param>
/// <param name="callback">转录完成后的回调,参数为转录文本</param>
public void TranscribeAudio(string audioFilePath, System.Action<string> callback)
{
    StartCoroutine(TranscribeAudioCoroutine(audioFilePath, callback));
}

AudioRecorder类中我们获取这个事件的返回值

csharp 复制代码
 /// <summary>
    /// 发送音频文件到转录API
    /// </summary>
    private void SendToTranscriptionAPI(string audioFilePath)
    {
        if (cozeSTTService == null)
        {
            Debug.LogError("CozeAudioTranscription服务未找到,无法发送转录请求");
            return;
        }
        
        Debug.Log($"发送音频到转录API: {audioFilePath}");
        
        // 调用转录服务
        cozeSTTService.TranscribeAudio(audioFilePath, (transcriptionText) => {
            if (!string.IsNullOrEmpty(transcriptionText))
            {
                Debug.Log($"转录结果: {transcriptionText}");
                // 显示转录结果到UI
                if (uiManager != null)
                {
                    uiManager.AppendToScrollView("Transcription: " + transcriptionText);
                }
            }
            else
            {
                Debug.LogError("转录失败");
                if (uiManager != null)
                {
                    uiManager.AppendToScrollView("Transcription failed: 无法识别音频内容");
                }
            }
            
            // 转录完成后删除临时文件
            try
            {
                if (File.Exists(audioFilePath))
                {
                    File.Delete(audioFilePath);
                    Debug.Log($"临时文件已删除: {audioFilePath}");
                }
            }
            catch (System.Exception ex)
            {
                Debug.LogError($"删除临时文件失败: {ex.Message}");
            }
        });
    }

StopRecordingAndTranscribe方法中调用它

csharp 复制代码
  /// <summary>
    /// 停止录音并发送到转录API
    /// </summary>
    public void StopRecordingAndTranscribe()
    {
...前面的代码不变        
        // 保存音频为WAV文件
        StartCoroutine(SaveAudioClipAsWav(trimmedClip, tempFilePath, (savedFilePath) => {
            if (!string.IsNullOrEmpty(savedFilePath))
            {
                // 发送到转录API
                SendToTranscriptionAPI(savedFilePath);
            }
        }));
    }

4. 将智能体理解的结果再发送回智能体

我们在UIManager中重新定义一个方法用于向智能体发送返回结果

csharp 复制代码
   public void SendMsg(string message)
    {
         // 通过CozePostAPI发送消息
            if (cozeAPI != null)
            {
                StartCoroutine(cozeAPI.SendMsg(message));
            }
    }
csharp 复制代码
在`AudioRecorder`的`SendToTranscriptionAPI`方法中调用它
/// <summary>
    /// 发送音频文件到转录API
    /// </summary>
    private void SendToTranscriptionAPI(string audioFilePath)
    {
...这里代码保持不变
                if (uiManager != null)
                {
                    uiManager.AppendToScrollView("You: " + transcriptionText);
                    uiManager.SendMsg(transcriptionText);
                }
            }
...这里代码保持不变
    }

此时当我们按下按钮说话后,智能体就能自动返回答案了

5. 源码

UIManager

csharp 复制代码
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using Unity.VisualScripting;
using UnityEngine.EventSystems;

public class UIManager : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
    public Button SubmitBtn;
    public InputField TextInput;
    public Text CallBackText;
    public ScrollRect CallBackscrollRect;
    public GameObject aboutPanel;
    public Button openAboutBtn;
    public Button closeBtn;
    public RawImage logoImg;
    public RawImage bgImg;
    public Text TitleText;
    public Text AboutText;
    public Button recordButton; // 录音按钮
    private CozeBotConfigService cozeBotConfigService;
    private CozePostAPI cozeAPI;
    private AudioRecorder audioRecorder; // 音频录制器

    private void Start()
    {
        // 获取CozePostAPI实例
        cozeAPI = FindObjectOfType<CozePostAPI>();
        cozeBotConfigService = FindObjectOfType<CozeBotConfigService>();
        audioRecorder = FindObjectOfType<AudioRecorder>();

        // 添加按钮点击事件监听器
        if (SubmitBtn != null)
        {
            SubmitBtn.onClick.AddListener(OnSendButtonClick);
        }
        // 添加打开关闭按钮点击事件监听器
        if (openAboutBtn != null)
        {
            openAboutBtn.onClick.AddListener(() => ObjIsOpen(aboutPanel, true));
        }
        if (closeBtn != null)
        {
            closeBtn.onClick.AddListener(() => ObjIsOpen(aboutPanel, false));
        }
        // 添加录音按钮事件监听器
        if (recordButton != null && audioRecorder != null)
        {
            // 获取或添加EventTrigger组件
            EventTrigger eventTrigger = recordButton.GetComponent<EventTrigger>();
            if (eventTrigger == null)
            {
                eventTrigger = recordButton.gameObject.AddComponent<EventTrigger>();
            }
            
            // 清除现有的事件
            eventTrigger.triggers.Clear();
            
            // 添加PointerDown事件
            EventTrigger.Entry pointerDownEntry = new EventTrigger.Entry();
            pointerDownEntry.eventID = EventTriggerType.PointerDown;
            pointerDownEntry.callback.AddListener((data) => { audioRecorder.StartRecording(); });
            pointerDownEntry.callback.AddListener((data) => { UpdateRecordButtonState(true); });
            eventTrigger.triggers.Add(pointerDownEntry);
            
            // 添加PointerUp事件
            EventTrigger.Entry pointerUpEntry = new EventTrigger.Entry();
            pointerUpEntry.eventID = EventTriggerType.PointerUp;
            pointerUpEntry.callback.AddListener((data) => { audioRecorder.StopRecordingAndTranscribe(); });
            pointerUpEntry.callback.AddListener((data) => { UpdateRecordButtonState(false); });
            eventTrigger.triggers.Add(pointerUpEntry);
        }

        // 初始化滚动视图文本
        if (CallBackText != null)
        {
            CallBackText.text = "";
        }
        ObjIsOpen(aboutPanel, false);
        logoImg.AddComponent<TextureLoader>();
        bgImg.AddComponent<TextureLoader>();
        // 加载图片
        StartCoroutine(LoadImagesWhenReady());
        // 加载文本
        StartCoroutine(LoadTextWhenReady());
    }

    private IEnumerator LoadImagesWhenReady()
    {
        // 等待直到配置服务中的图片字段有内容
        while (string.IsNullOrEmpty(cozeBotConfigService.logoImg) || string.IsNullOrEmpty(cozeBotConfigService.bgImg))
        {
            yield return null; // 等待下一帧
        }

        // 配置已准备好,加载图片
        logoImg.GetComponent<TextureLoader>().LoadTextureToRawImage(cozeBotConfigService.logoImg, logoImg);
        bgImg.GetComponent<TextureLoader>().LoadTextureToRawImage(cozeBotConfigService.bgImg, bgImg);
    }
    private IEnumerator LoadTextWhenReady()
    {
        // 等待直到配置服务中的文本字段有内容
        while (string.IsNullOrEmpty(cozeBotConfigService.botName) || string.IsNullOrEmpty(cozeBotConfigService.aboutText))
        {
            yield return null; // 等待下一帧
        }
        // 配置已准备好,加载文本
        TitleText.text = cozeBotConfigService.botName;
        AboutText.text = cozeBotConfigService.aboutText;
    }
    // 发送按钮点击事件
    private void OnSendButtonClick()
    {
        if (TextInput != null && !string.IsNullOrEmpty(TextInput.text))
        {
            string message = TextInput.text;

            // 在滚动视图中显示用户输入
            AppendToScrollView("You: " + message);

            // 清空输入框
            TextInput.text = "";
            SendMsg(message); 
        }
    }
    public void SendMsg(string message)
    {
         // 通过CozePostAPI发送消息
            if (cozeAPI != null)
            {
                StartCoroutine(cozeAPI.SendMsg(message));
            }
    }
    // 向滚动视图添加文本
    public void AppendToScrollView(string text)
    {
        if (CallBackText != null)
        {
            CallBackText.text += text + "\n";

            // 延迟更新滚动位置,确保文本完全添加
            StartCoroutine(UpdateScrollPosition());
        }
    }

    // 更新滚动视图位置到底部
    private IEnumerator UpdateScrollPosition()
    {
        yield return null;

        if (CallBackscrollRect != null)
        {
            CallBackscrollRect.verticalNormalizedPosition = 0f;
        }
    }
    //面板互动的方法
    private void ObjIsOpen(GameObject obj,bool isOpen)
    {
        obj.SetActive(isOpen);
    }
    // 接收消息的方法,供外部调用
    public void ReceiveMessage(string message)
    {
        AppendToScrollView("Coze: " + message);
    }
    
    /// <summary>
    /// 更新录音按钮状态
    /// </summary>
    /// <param name="isRecording">是否正在录音</param>
    public void UpdateRecordButtonState(bool isRecording)
    {
        if (recordButton != null)
        {
            ColorBlock colors = recordButton.colors;
            if (isRecording)
            {
               recordButton.GetComponentInChildren<Text>().text = "松开识别";
            }
            else
            {
                recordButton.GetComponentInChildren<Text>().text = "按住说话";
            }
            recordButton.colors = colors;
        }
    }
    
    /// <summary>
    /// 实现IPointerDownHandler接口
    /// </summary>
    public void OnPointerDown(PointerEventData eventData)
    {
        // 这个方法主要是为了满足接口要求,实际的按钮事件通过EventTrigger处理
    }
    
    /// <summary>
    /// 实现IPointerUpHandler接口
    /// </summary>
    public void OnPointerUp(PointerEventData eventData)
    {
        // 这个方法主要是为了满足接口要求,实际的按钮事件通过EventTrigger处理
    }
}

AudioRecorder

csharp 复制代码
using UnityEngine;
using System.Collections;
using System.IO;

public class AudioRecorder : MonoBehaviour
{
    [Header("录音设置")]
    public string deviceName = ""; // 麦克风设备名称
    public int sampleRate = 44100; // 采样率
    public int channels = 1; // 声道数
    
    [Header("文件设置")]
    public string tempFilePath = "temp_recording.wav"; // 临时音频文件路径
    
    private AudioClip recordingClip; // 录制的音频片段
    private bool isRecording = false; // 是否正在录音
    private CozeSTTService cozeSTTService; // 音频转录服务
    private UIManager uiManager; // UI管理器
    
    private void Start()
    {
        // 获取CozeAudioTranscription服务
        cozeSTTService = FindObjectOfType<CozeSTTService>();
        if (cozeSTTService == null)
        {
            Debug.LogError("CozeSTTService服务未找到,请确保场景中有该组件");
        }
        
        // 获取UIManager
        uiManager = FindObjectOfType<UIManager>();
        if (uiManager == null)
        {
            Debug.LogError("UIManager未找到,请确保场景中有该组件");
        }
        
        // 获取默认麦克风设备名称
        if (string.IsNullOrEmpty(deviceName) && Microphone.devices.Length > 0)
        {
            deviceName = Microphone.devices[0];
            Debug.Log($"使用默认麦克风设备: {deviceName}");
        }
    }
    
    /// <summary>
    /// 开始录音
    /// </summary>
    public void StartRecording()
    {
        if (isRecording)
        {
            Debug.LogWarning("已经在录音中");
            return;
        }
        
        if (string.IsNullOrEmpty(deviceName))
        {
            Debug.LogError("未找到麦克风设备");
            return;
        }
        
        Debug.Log("开始录音...");
        
        // 开始录音
        recordingClip = Microphone.Start(deviceName, false, 60, sampleRate); // 最大录音时长60秒
        isRecording = true;
    }
    
    /// <summary>
    /// 停止录音并发送到转录API
    /// </summary>
    public void StopRecordingAndTranscribe()
    {
        if (!isRecording)
        {
            Debug.LogWarning("没有在录音中");
            return;
        }
        
        Debug.Log("停止录音...");
        
        // 停止录音
        int position = Microphone.GetPosition(deviceName);
        Microphone.End(deviceName);
        isRecording = false;
        
        // 裁剪音频片段到实际录制长度
        AudioClip trimmedClip = AudioClip.Create("TrimmedRecording", position, channels, sampleRate, false);
        float[] data = new float[position];
        recordingClip.GetData(data, 0);
        trimmedClip.SetData(data, 0);
        
        // 保存音频为WAV文件
        StartCoroutine(SaveAudioClipAsWav(trimmedClip, tempFilePath, (savedFilePath) => {
            if (!string.IsNullOrEmpty(savedFilePath))
            {
                // 发送到转录API
                SendToTranscriptionAPI(savedFilePath);
            }
        }));
    }
    
    /// <summary>
    /// 保存AudioClip为WAV文件
    /// </summary>
    private IEnumerator SaveAudioClipAsWav(AudioClip clip, string filePath, System.Action<string> callback)
    {
        // 确保目录存在
        string directory = Path.GetDirectoryName(filePath);
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
        
        // 转换为WAV格式并保存
        yield return StartCoroutine(EncodeAndSaveWav(clip, filePath));
        
        if (File.Exists(filePath))
        {
            Debug.Log($"音频文件保存成功: {filePath}");
            callback?.Invoke(filePath);
        }
        else
        {
            Debug.LogError("音频文件保存失败");
            callback?.Invoke(null);
        }
    }
    /// <summary>
    /// 编码并保存WAV文件
    /// </summary>
    private IEnumerator EncodeAndSaveWav(AudioClip clip, string filePath)
    {
        // 获取音频数据
        float[] samples = new float[clip.samples * clip.channels];
        clip.GetData(samples, 0);
        
        // 转换为16位PCM数据
        short[] pcmData = new short[samples.Length];
        for (int i = 0; i < samples.Length; i++)
        {
            pcmData[i] = (short)(samples[i] * 32767);
        }
        
        // 写入WAV文件
        using (FileStream fileStream = new FileStream(filePath, FileMode.Create))
        using (BinaryWriter writer = new BinaryWriter(fileStream))
        {
            // RIFF头
            writer.Write(new char[] { 'R', 'I', 'F', 'F' });
            writer.Write(36 + pcmData.Length * 2);
            writer.Write(new char[] { 'W', 'A', 'V', 'E' });
            
            // fmt块
            writer.Write(new char[] { 'f', 'm', 't', ' ' });
            writer.Write(16);
            writer.Write((short)1); // PCM格式
            writer.Write((short)clip.channels);
            writer.Write(clip.frequency);
            writer.Write(clip.frequency * clip.channels * 2); // 字节率
            writer.Write((short)(clip.channels * 2)); // 块对齐
            writer.Write((short)16); // 位深度
            
            // data块
            writer.Write(new char[] { 'd', 'a', 't', 'a' });
            writer.Write(pcmData.Length * 2);
            
            // 写入PCM数据
            for (int i = 0; i < pcmData.Length; i++)
            {
                writer.Write(pcmData[i]);
            }
        }
        
        yield return null;
    }
    
    /// <summary>
    /// 发送音频文件到转录API
    /// </summary>
    private void SendToTranscriptionAPI(string audioFilePath)
    {
        if (cozeSTTService == null)
        {
            Debug.LogError("CozeAudioTranscription服务未找到,无法发送转录请求");
            return;
        }
        
        Debug.Log($"发送音频到转录API: {audioFilePath}");
        
        // 调用转录服务
        cozeSTTService.TranscribeAudio(audioFilePath, (transcriptionText) => {
            if (!string.IsNullOrEmpty(transcriptionText))
            {
                Debug.Log($"转录结果: {transcriptionText}");
                // 显示转录结果到UI
                if (uiManager != null)
                {
                    uiManager.AppendToScrollView("You: " + transcriptionText);
                    uiManager.SendMsg(transcriptionText);
                }
            }
            else
            {
                Debug.LogError("转录失败");
                if (uiManager != null)
                {
                    uiManager.AppendToScrollView("Transcription failed: 无法识别音频内容");
                }
            }
            
            // 转录完成后删除临时文件
            try
            {
                if (File.Exists(audioFilePath))
                {
                    File.Delete(audioFilePath);
                    Debug.Log($"临时文件已删除: {audioFilePath}");
                }
            }
            catch (System.Exception ex)
            {
                Debug.LogError($"删除临时文件失败: {ex.Message}");
            }
        });
    }
    
    /// <summary>
    /// 获取可用的麦克风设备列表
    /// </summary>
    public void ListMicrophoneDevices()
    {
        if (Microphone.devices.Length > 0)
        {
            Debug.Log("可用的麦克风设备:");
            for (int i = 0; i < Microphone.devices.Length; i++)
            {
                Debug.Log($"{i}: {Microphone.devices[i]}");
            }
        }
        else
        {
            Debug.LogError("未找到可用的麦克风设备");
        }
    }
}

CozeSTTService

csharp 复制代码
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using Newtonsoft.Json.Linq;
using System.IO;

public class CozeSTTService : MonoBehaviour
{
    private string token;
    private string apiUrl = "https://api.coze.cn/v1/audio/transcriptions";
    private CozeBotConfigService configService;

    private void Start()
    {
        // 从CozeBotConfigService获取token
        configService = FindObjectOfType<CozeBotConfigService>();
        token = configService.token;
    }

    /// <summary>
    /// 发送音频文件进行转录
    /// </summary>
    /// <param name="audioFilePath">音频文件路径</param>
    /// <param name="callback">转录完成后的回调,参数为转录文本</param>
    public void TranscribeAudio(string audioFilePath, System.Action<string> callback)
    {
        StartCoroutine(TranscribeAudioCoroutine(audioFilePath, callback));
    }

    /// <summary>
    /// 发送音频文件进行转录的协程
    /// </summary>
    private IEnumerator TranscribeAudioCoroutine(string audioFilePath, System.Action<string> callback)
    {
        if (string.IsNullOrEmpty(audioFilePath))
        {
            Debug.LogError("音频文件路径为空");
            callback?.Invoke(null);
            yield break;
        }

        if (!File.Exists(audioFilePath))
        {
            Debug.LogError($"音频文件不存在: {audioFilePath}");
            callback?.Invoke(null);
            yield break;
        }

        if (string.IsNullOrEmpty(token))
        {
            Debug.LogError("Token为空");
            callback?.Invoke(null);
            yield break;
        }
        // 创建表单数据
        WWWForm form = new WWWForm();
        // 读取音频文件
        byte[] audioData = File.ReadAllBytes(audioFilePath);
        // 添加文件到表单
        form.AddBinaryData("file", audioData, Path.GetFileName(audioFilePath), "audio/mpeg");

        // 创建UnityWebRequest
        using (UnityWebRequest request = UnityWebRequest.Post(apiUrl, form))
        {
            // 设置Authorization头
            request.SetRequestHeader("Authorization", "Bearer " + token);

            // 发送请求
            yield return request.SendWebRequest();

            // 处理响应
            if (request.result == UnityWebRequest.Result.Success)
            {
                Debug.Log("转录请求成功: " + request.downloadHandler.text);
                
                // 解析响应
                string transcriptionText = ParseTranscriptionResponse(request.downloadHandler.text);
                callback?.Invoke(transcriptionText);
            }
            else
            {
                Debug.LogError($"转录请求失败: {request.error}");
                Debug.LogError($"响应内容: {request.downloadHandler.text}");
                callback?.Invoke(null);
            }
        }
    }

    /// <summary>
    /// 解析转录响应
    /// </summary>
    /// <param name="responseText">响应文本</param>
    /// <returns>转录文本</returns>
    private string ParseTranscriptionResponse(string responseText)
    {
        try
        {
            JObject jsonObject = JObject.Parse(responseText);
            
            // 检查code是否为0
            if (jsonObject["code"] != null && jsonObject["code"].ToString() == "0")
            {
                // 提取data中的text字段
                if (jsonObject["data"] != null && jsonObject["data"]["text"] != null)
                {
                    return jsonObject["data"]["text"].ToString();
                }
                else
                {
                    Debug.LogError("响应中缺少data.text字段");
                }
            }
            else
            {
                string errorMsg = jsonObject["msg"]?.ToString() ?? "未知错误";
                Debug.LogError($"转录失败: {errorMsg}");
            }
        }
        catch (System.Exception ex)
        {
            Debug.LogError($"解析响应失败: {ex.Message}");
        }

        return null;
    }
}
相关推荐
驴友花雕1 小时前
【花雕动手做】行空板 K10 系列实验之人工智能语音识别小车的10个参考案例
人工智能·单片机·嵌入式硬件·语音识别·行空板 k10 系列实验·花雕动手做·小车的10个参考案例
一笑的小酒馆1 小时前
Androidiot蓝牙配网简单封装
android
怕浪猫1 小时前
DeepSeek Harness 源码实战第2章:Cordis——驱动 dsh 的插件引擎
aigc·openai·agent
愚公搬代码4 小时前
【愚公系列】《Android应用案例开发大全》016-LBS类应用掌上杭州(辅助工具类的开发)
android·前端
g10565591395 小时前
LAMP博客平台Wordpress实战
android·mysql·nginx·php
王莹月5 小时前
生图API 出问题怎么定位?给调用加 traceId 和结构化日志(nano-banana-pro)
gpt·ai·chatgpt·ai作画·aigc·agi
hunterandroid6 小时前
[Android 从零到一] Compose LazyColumn 性能优化:key、稳定性与重组治理
android·前端
pengyu6 小时前
【Kotlin 协程修仙录 · 元婴境 · 中阶】 | 操作符真解:Flow 中间操作符与数据流变幻术
android·kotlin
pengyu7 小时前
【Kotlin 协程修仙录 · 元婴境 · 初阶】 | 冷流初啼:Flow 的基础与响应式编程的入门
android·kotlin