<think>根据讯飞ISE API文档,我们需要实现以下功能:
1. 录音功能:在Unity中,我们可以使用Microphone类进行录音。
2. 在录音结束后,将录音数据发送到WebSocket服务器。
3. 接收并解析服务器返回的评测结果。
步骤:
1. 在Start方法中,我们已经在建立WebSocket连接后发送了初始化帧(InitFrame)。根据文档,初始化帧发送后,服务器会返回一个sid(会话ID),后续的数据帧需要带上这个sid。但注意,我们目前没有处理这个sid,所以需要在OnMessage回调中解析初始化帧的响应,获取sid。
2. 开始录音:我们可以在连接建立并发送初始化帧后开始录音。
3. 结束录音:例如,我们可以设定一个录音时长(比如5秒),或者通过一个按钮事件来结束录音。
4. 录音结束后,将音频数据按照要求(16000采样率,16位,单声道)进行处理,并分帧发送(每帧640字节,即320ms的音频数据,因为16000采样率,16位=2字节,所以每帧数据量=16000*2*0.32=10240字节?不对,需要重新计算)。
实际上,文档中要求每次发送音频数据为640字节(即320个采样点,因为每个采样点2字节)。注意:16000采样率下,320个采样点对应20ms的音频(320/16000=0.02秒)。所以每帧音频数据为640字节,每次发送一帧。
5. 发送完所有音频数据后,发送一个结束帧(status=2)。
6. 接收服务器返回的结果,并解析。
修改点:
1. 在Start协程中,发送初始化帧后开始录音。
2. 在Update中,除了处理消息队列,还需要在录音过程中不断读取麦克风数据,并发送音频帧。
3. 添加结束录音的逻辑(比如按某个键结束,或者固定时长结束)。
4. 在OnMessage回调中,解析服务器返回的消息,特别是初始化帧的响应(包含sid)和最终结果。
注意:根据文档,音频数据需要是PCM格式,16位,单声道,16000采样率。
具体步骤:
1. 修改Start协程:在发送初始化帧后,开始录音。
2. 定义录音相关变量:
- AudioClip对象
- 录音是否开始的标志
- 录音结束标志
- 用于存储录音数据的缓冲区(或者直接使用Microphone.GetPosition和AudioClip.GetData来读取)
3. 由于WebSocket是异步的,我们可以在Update中读取麦克风数据并发送。
但是,注意:录音数据是连续的,我们需要在录音过程中不断读取新的数据并发送。我们可以使用一个协程或者每帧读取。
考虑到每帧发送的数据量很小(640字节,即20ms),我们可以每帧读取并发送,但要注意避免发送过快,因为实际录音速度是固定的。我们可以使用一个缓冲区,每次读取一定量的数据,然后发送。
然而,Unity的Microphone不会自动提供回调,所以我们需要自己循环读取。
另一种做法:开始录音后,启动一个协程,每隔20ms读取一次数据并发送。但这样可能会因为协程调度不精确导致数据丢失。因此,我们可以在Update中,根据已经录制的总长度,计算是否有新的数据可以读取。
实现步骤:
定义变量:
private AudioClip recordingClip;
private bool isRecording = false;
private int recordingPosition = 0; // 当前录音位置(采样点)
private string sid = null; // 用于存储初始化帧返回的sid
修改Start协程:
在发送初始化帧后,开始录音:
recordingClip = Microphone.Start(null, false, 10, 16000); // 10秒长度,16000采样率
isRecording = true;
在Update中:
处理消息队列(已有)
如果正在录音,则检查录音位置,读取新的数据并发送。
如何读取数据:
获取当前录音位置:int currentPos = Microphone.GetPosition(null);
计算可读取的采样点数:currentPos - recordingPosition
注意:由于环形缓冲区的存在,如果currentPos小于recordingPosition,表示已经回绕(但我们录音时间短,暂不考虑)
将可读取的采样点数转换为字节数(每个采样点2字节)。
每次读取至少640字节(320个采样点)的数据,然后发送一帧。
但是,由于Update每帧执行一次,而音频数据是连续产生的,我们需要确保每次发送的数据量是640字节的整数倍?实际上,我们可以每次读取所有可读数据,然后按照640字节分帧发送。
注意:文档要求每次发送640字节(320个采样点)的数据。
因此,我们可以:
int sampleSize = currentPos - recordingPosition;
if (sampleSize < 0) sampleSize += recordingClip.samples; // 处理回绕,但一般不会发生,因为我们录音时间短
int byteSize = sampleSize * 2; // 每个采样点2字节
// 计算有多少个完整的640字节块
int numChunks = byteSize / 640;
if (numChunks > 0) {
// 读取数据
float[] samples = new float[sampleSize];
recordingClip.GetData(samples, recordingPosition);
// 将float数组转换为byte数组(注意:Microphone返回的是float数组,范围在-1到1,需要转换为16位整数)
// 但是我们之前使用Microphone.Start时,返回的AudioClip是16位整数的吗?实际上,Unity的AudioClip.GetData返回的是float数组。
// 所以我们需要将float数组转换为16位整数(short)的字节数组。
// 创建一个足够大的字节数组
byte[] audioData = new byte[sampleSize * 2];
for (int i=0; i<sampleSize; i++) {
// 将float转换为short
short value = (short)(samples[i] * 32767);
byte[] bytes = BitConverter.GetBytes(value);
audioData[i*2] = bytes[0];
audioData[i*2+1] = bytes[1];
}
// 然后,将audioData按照640字节分块发送
for (int i=0; i<numChunks; i++) {
byte[] chunk = new byte[640];
Buffer.BlockCopy(audioData, i*640, chunk, 0, 640);
SendAudioFrame(chunk);
}
// 更新recordingPosition
recordingPosition = (recordingPosition + numChunks * 320) % recordingClip.samples;
}
但是,这样处理可能效率不高,因为每帧都要读取整个缓冲区并转换,而且如果缓冲区很大,可能会造成卡顿。
优化:我们可以每次只读取320个采样点(640字节)的数据,然后发送。但是,如何确保每次读取的位置正确?
另一种做法:我们使用一个队列,在另一个线程中读取麦克风数据,但Unity中多线程操作Unity对象比较麻烦。
因此,我们简化:每次读取当前可读数据,然后分帧发送。由于每帧发送的数据量很小(20ms),所以每帧最多可能发送几帧数据(取决于帧率,但不会太多)。
但是,如果游戏帧率很低,可能会导致数据堆积。所以,我们最好确保每帧都处理,并且游戏帧率要高于50帧(即每帧时间小于20ms)?这不一定,所以我们需要能够处理堆积的数据。
我们按照上述思路实现,但注意:如果可读数据不足640字节,则等待下一帧。
另外,结束录音:我们可以设定一个录音时长,比如5秒后自动结束。或者通过按键结束。
修改:
添加一个结束录音的方法,比如按空格键停止录音。
在Update中:
if (Input.GetKeyDown(KeyCode.Space)) {
StopRecording();
}
停止录音时,需要将剩余数据发送(如果可读数据不是640的整数倍,则丢弃剩余部分?但文档要求每次640字节,所以只发送完整帧),然后发送结束帧(status=2)。
另外,在初始化帧的响应中,我们需要解析出sid,并在后续的数据帧中带上这个sid。
根据文档,初始化帧的响应格式:
{
"data": {
"sid": "xxx"
},
"code": 0,
"message": "success"
}
所以,在OnMessage回调中,我们需要解析这个sid并存储。
数据帧格式:
{
"data": {
"status": 1, // 1表示中间数据,2表示结束
"data": "base64编码的音频数据"
},
"sid": "初始化返回的sid"
}
因此,发送音频帧的函数需要sid。
步骤总结:
1. 连接WebSocket,发送初始化帧。
2. 开始录音。
3. 在Update中,读取麦克风数据并分帧发送(每帧640字节)。
4. 当收到初始化响应时,保存sid。
5. 当停止录音时,发送结束帧(status=2)。
6. 解析服务器返回的结果。
代码修改:
首先,添加成员变量:
private string sid = null;
private AudioClip recordingClip = null;
private bool isRecording = false;
private int recordingPosition = 0; // 当前录音位置(采样点)
然后,修改OnMessage回调,处理初始化帧的响应:
websocket.OnMessage += (data) => {
string json = Encoding.UTF8.GetString(data);
Debug.Log("Received: " + json);
// 解析JSON
try {
var jsonObj = JsonUtility.FromJson<ISEBaseResponse>(json);
if (jsonObj.data != null && jsonObj.data.sid != null) {
sid = jsonObj.data.sid;
Debug.Log("Got sid: " + sid);
}
// 如果收到结果帧(status=2),则解析结果
// 注意:结果帧可能有多个(中间结果和最终结果),但最终结果会有一个结束标志
// 根据文档,当status=2时,表示最后一个数据块,服务器会返回最终结果
// 但是,我们发送结束帧后,服务器会返回最终结果,所以在这里可以解析
if (jsonObj.code == 0 && jsonObj.data != null && jsonObj.data.status == 2) {
// 解析结果
Debug.Log("Final result: " + json);
}
} catch (Exception e) {
Debug.LogError("Parse message error: " + e.Message);
}
};
注意:我们需要定义对应的数据结构来解析JSON。由于讯飞返回的JSON结构比较复杂,我们需要定义多个类。
但是,为了简化,我们可以先定义基础结构:
[Serializable]
public class ISEBaseResponse
{
public int code;
public string message;
public ISEData data;
}
[Serializable]
public class ISEData
{
public string sid;
public int status; // 用于数据帧响应
public string data; // 对于初始化响应,这里没有data字段,但可能有sid;对于结果,这里包含base64编码的XML结果
// 其他字段...
}
但是,注意:初始化响应中,data是一个对象,包含sid,而结果帧中data字段是字符串(base64编码的XML)。所以我们需要更灵活的处理。
由于时间关系,我们简化处理:只解析sid,然后对于结果帧,我们直接打印。
接下来,实现发送音频帧的方法:
void SendAudioFrame(byte[] audioChunk)
{
if (sid == null) {
Debug.LogWarning("Sid not received, skip audio frame");
return;
}
// 构造数据帧
string base64Audio = Convert.ToBase64String(audioChunk);
string audioFrameJson = @"{
""data"": {
""status"": 1,
""data"": """ + base64Audio + @"""
},
""sid"": """ + sid + @"""
}";
websocket.Send(Encoding.UTF8.GetBytes(audioFrameJson));
}
注意:在发送结束帧时,我们发送一个状态为2的帧,数据部分为空(或者最后一帧音频数据?)。但是,我们在停止录音时,可能还有最后一帧不完整的音频数据,我们只发送完整的帧,所以结束帧只发送状态为2,数据部分为空。
void SendEndFrame()
{
if (sid == null) {
Debug.LogWarning("Sid not received, skip end frame");
return;
}
string endFrameJson = @"{
""data"": {
""status"": 2,
""data"": """"
},
""sid"": """ + sid + @"""
}";
websocket.Send(Encoding.UTF8.GetBytes(endFrameJson));
}
然后,修改Update,处理录音数据:
void Update()
{
// 处理消息队列
if (websocket != null && isConnected)
{
#if !UNITY_WEBGL || UNITY_EDITOR
websocket.DispatchMessageQueue();
#endif
}
// 录音处理
if (isRecording && recordingClip != null)
{
// 获取当前录音位置
int currentPos = Microphone.GetPosition(null);
int sampleSize = currentPos - recordingPosition;
if (sampleSize < 0) // 处理环形缓冲区
{
sampleSize += recordingClip.samples;
}
// 如果可读取的采样点数大于0
if (sampleSize > 0)
{
// 计算字节数
int byteSize = sampleSize * 2; // 每个采样点2字节
// 计算完整帧数(每帧320个采样点,640字节)
int numFrames = sampleSize / 320;
if (numFrames > 0)
{
// 读取数据:从recordingPosition开始,读取numFrames*320个采样点
float[] samples = new float[numFrames * 320];
recordingClip.GetData(samples, recordingPosition);
// 转换为字节数组
byte[] audioData = new byte[numFrames * 640];
for (int i = 0; i < samples.Length; i++)
{
// 将float转换为short
short value = (short)(samples[i] * 32767);
byte[] bytes = BitConverter.GetBytes(value);
audioData[i * 2] = bytes[0];
audioData[i * 2 + 1] = bytes[1];
}
// 分帧发送
for (int i = 0; i < numFrames; i++)
{
byte[] frame = new byte[640];
Buffer.BlockCopy(audioData, i * 640, frame, 0, 640);
SendAudioFrame(frame);
}
// 更新recordingPosition
recordingPosition = (recordingPosition + numFrames * 320) % recordingClip.samples;
}
}
}
// 按空格键停止录音
if (Input.GetKeyDown(KeyCode.Space) && isRecording)
{
StopRecording();
}
}
void StopRecording()
{
if (isRecording)
{
isRecording = false;
Microphone.End(null);
// 发送结束帧
SendEndFrame();
Debug.Log("Recording stopped.");
}
}
注意:由于我们每帧最多发送numFrames帧,所以不会造成堆积(因为录音速度是固定的,每帧产生的数据量是固定的,而我们的帧率远高于50Hz,所以可以及时发送)。
但是,如果游戏帧率很低,可能会导致延迟。我们可以考虑使用协程来定时发送,但这里为了简单,使用Update。
另外,在开始录音时,我们设定了10秒的缓冲区,所以录音最多持续10秒。
最后,在OnDestroy中停止录音和关闭WebSocket。
注意:在发送结束帧后,服务器会返回评测结果,我们在OnMessage回调中解析。
由于结果是一个base64编码的XML字符串,我们可以将其解码,然后解析XML。但根据需求,我们可以直接显示或者处理。
这里,我们只打印结果。
修改OnMessage回调,当收到最终结果时(status=2),解析base64编码的XML:
if (jsonObj.code == 0 && jsonObj.data != null && jsonObj.data.status == 2) {
string base64Result = jsonObj.data.data;
if (!string.IsNullOrEmpty(base64Result)) {
byte[] bytes = Convert.FromBase64String(base64Result);
string xmlResult = Encoding.UTF8.GetString(bytes);
Debug.Log("Result XML: " + xmlResult);
// 这里可以解析XML,获取评测结果
}
}
以上是一个基本的实现。由于代码较长,下面给出完整的修改后的代码。
注意:由于讯飞API的响应结构可能复杂,我们定义的数据结构可能不完整,实际使用中需要根据返回的JSON调整。
完整代码:
注意:我们添加了必要的类和成员变量,并修改了Start和Update方法。
由于代码较长,请仔细阅读注释。</think>根据讯飞ISE API文档和你的需求,我将为你的代码添加录音功能、音频数据发送和评测结果解析功能。以下是完整的实现方案:
```csharp
using UnityEngine;
using System;
using System.Text;
using System.Collections;
using System.Security.Cryptography;
using NativeWebSocket;
using System.Globalization;
public class XunfeiISEController : MonoBehaviour
{
// 配置参数
const string APP_ID = "d11f22d5";
const string API_KEY = "6c1a2fc3bac9767a92aeb80098c89b61";
const string API_SECRET = "YjJjZmM1MjI3N2ZjMmFiZmQ2Y2E2NWI1";
const string HOST = "ise-api.xfyun.cn";
const string PATH = "/v2/open-ise";
WebSocket websocket;
bool isConnected = false;
private AudioClip recordingClip;
private bool isRecording = false;
private int recordingPosition = 0;
private string sid = null;
// 定义JSON解析结构
[Serializable]
public class ISEResponse
{
public int code;
public string message;
public ISEData data;
}
[Serializable]
public class ISEData
{
public string sid;
public int status;
public string data;
}
IEnumerator Start()
{
// 1. 生成鉴权URL
string authUrl = GenerateAuthUrl();
Debug.Log("Connecting to: " + authUrl);
// 2. 创建WebSocket连接
websocket = new WebSocket(authUrl);
// 设置事件回调
websocket.OnOpen += () => {
Debug.Log("WebSocket connected!");
isConnected = true;
};
websocket.OnError += (err) => {
Debug.LogError("Error: " + err);
isConnected = false;
};
websocket.OnClose += (code) => {
Debug.Log("Connection closed: " + code);
isConnected = false;
};
websocket.OnMessage += (data) => {
string json = Encoding.UTF8.GetString(data);
Debug.Log("Received: " + json);
// 解析结果
try {
ISEResponse response = JsonUtility.FromJson<ISEResponse>(json);
// 保存会话ID
if (response.data != null && !string.IsNullOrEmpty(response.data.sid)) {
sid = response.data.sid;
Debug.Log("Session ID: " + sid);
}
// 处理最终评测结果
if (response.data != null && response.data.status == 2) {
if (!string.IsNullOrEmpty(response.data.data)) {
// 解码Base64结果
byte[] resultBytes = Convert.FromBase64String(response.data.data);
string xmlResult = Encoding.UTF8.GetString(resultBytes);
Debug.Log("Final Result XML:\n" + xmlResult);
// 这里可以添加XML解析代码提取具体分数
ParseResultXML(xmlResult);
}
}
} catch (Exception e) {
Debug.LogError("Parse error: " + e.Message);
}
};
// 3. 异步连接
websocket.Connect();
// 4. 等待连接建立(最多5秒)
float timeout = 5f;
float startTime = Time.time;
while (!isConnected && Time.time - startTime < timeout) {
yield return null;
}
if (!isConnected) {
Debug.LogError("Connection timeout");
yield break;
}
// 5. 发送初始化参数帧
SendInitFrame();
}
// 生成鉴权URL
string GenerateAuthUrl()
{
// 获取RFC1123格式的GMT时间
string date = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture);
// 构建签名字符串
string signatureOrigin = $"host: {HOST}\ndate: {date}\nGET {PATH} HTTP/1.1";
// HMAC-SHA256签名
byte[] signatureBytes = HMACSHA256(
Encoding.UTF8.GetBytes(API_SECRET),
Encoding.UTF8.GetBytes(signatureOrigin)
);
string signature = Convert.ToBase64String(signatureBytes);
// 构建授权参数
string authorization = $"api_key=\"{API_KEY}\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"{signature}\"";
// Base64编码授权参数
string encodedAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authorization));
// 构造完整URL
return $"wss://{HOST}{PATH}?authorization={encodedAuth}&date={Uri.EscapeDataString(date)}&host={HOST}";
}
// 发送初始化帧
void SendInitFrame()
{
// 评测文本
string textContent = "When you don't know what you're doing, it's helpful to begin by learning about what you should not do.";
// 构建初始化帧数据结构
string initJson = @"{
""common"": {
""app_id"": """ + APP_ID + @"""
},
""business"": {
""aue"": ""raw"",
""auf"": ""audio/L16;rate=16000"",
""category"": ""read_sentence"",
""cmd"": ""ssb"",
""ent"": ""en_vip"",
""sub"": ""ise"",
""text"": """ + Convert.ToBase64String(Encoding.UTF8.GetBytes(textContent)) + @""",
""ttp_skip"": true
},
""data"": {
""status"": 0
}
}";
// 发送初始化帧
websocket.Send(Encoding.UTF8.GetBytes(initJson));
Debug.Log("Sent init frame: " + initJson);
}
// 开始录音
public void StartRecording()
{
if (!isRecording)
{
Debug.Log("Starting recording...");
recordingClip = Microphone.Start(null, false, 10, 16000); // 10秒缓冲区
recordingPosition = 0;
isRecording = true;
}
}
// 停止录音并发送数据
public void StopRecordingAndSend()
{
if (isRecording)
{
Debug.Log("Stopping recording...");
Microphone.End(null);
isRecording = false;
// 发送剩余音频数据
SendAudioFrames();
// 发送结束帧
SendEndFrame();
}
}
// 发送音频数据帧
private void SendAudioFrames()
{
if (recordingClip == null || string.IsNullOrEmpty(sid)) return;
int currentPos = Microphone.GetPosition(null);
int sampleSize = currentPos - recordingPosition;
if (sampleSize < 0) sampleSize += recordingClip.samples;
if (sampleSize > 0)
{
// 读取音频数据
float[] samples = new float[sampleSize];
recordingClip.GetData(samples, recordingPosition);
// 转换为16位PCM
byte[] pcmData = new byte[sampleSize * 2];
for (int i = 0; i < sampleSize; i++)
{
short value = (short)(samples[i] * 32767);
byte[] bytes = BitConverter.GetBytes(value);
pcmData[i * 2] = bytes[0];
pcmData[i * 2 + 1] = bytes[1];
}
// 分帧发送(每帧640字节 = 320ms)
int frameSize = 640;
for (int offset = 0; offset < pcmData.Length; offset += frameSize)
{
int remaining = pcmData.Length - offset;
int sendSize = Math.Min(remaining, frameSize);
byte[] frame = new byte[sendSize];
Buffer.BlockCopy(pcmData, offset, frame, 0, sendSize);
SendAudioFrame(frame);
}
recordingPosition = currentPos;
}
}
// 发送单帧音频数据
private void SendAudioFrame(byte[] audioData)
{
if (websocket == null || !isConnected || string.IsNullOrEmpty(sid)) return;
string base64Audio = Convert.ToBase64String(audioData);
string audioFrame = @"{
""data"": {
""status"": 1,
""data"": """ + base64Audio + @"""
},
""sid"": """ + sid + @"""
}";
websocket.Send(Encoding.UTF8.GetBytes(audioFrame));
Debug.Log($"Sent audio frame: {audioData.Length} bytes");
}
// 发送结束帧
private void SendEndFrame()
{
if (websocket == null || !isConnected || string.IsNullOrEmpty(sid)) return;
string endFrame = @"{
""data"": {
""status"": 2,
""data"": """"
},
""sid"": """ + sid + @"""
}";
websocket.Send(Encoding.UTF8.GetBytes(endFrame));
Debug.Log("Sent end frame");
}
// 解析评测结果XML
private void ParseResultXML(string xml)
{
try
{
// 这里简化解析,实际应用中应使用XML解析器
if (xml.Contains("<read_chapter>"))
{
int start = xml.IndexOf("<total_score>") + "<total_score>".Length;
int end = xml.IndexOf("</total_score>");
if (start > 0 && end > start)
{
string scoreStr = xml.Substring(start, end - start);
if (float.TryParse(scoreStr, out float score))
{
Debug.Log($"Total Score: {score}");
}
}
}
}
catch (Exception e)
{
Debug.LogError("XML parse error: " + e.Message);
}
}
void Update()
{
// 处理消息队列
if (websocket != null && isConnected)
{
#if !UNITY_WEBGL || UNITY_EDITOR
websocket.DispatchMessageQueue();
#endif
}
// 录音时发送音频帧
if (isRecording && isConnected && !string.IsNullOrEmpty(sid))
{
SendAudioFrames();
}
}
void OnDestroy()
{
if (websocket != null)
{
websocket.Close();
}
if (isRecording)
{
Microphone.End(null);
}
}
// HMAC-SHA256计算
byte[] HMACSHA256(byte[] key, byte[] data)
{
using (var hmac = new HMACSHA256(key))
{
return hmac.ComputeHash(data);
}
}
}
```
### 代码解释:
1. **录音功能**:
- 添加了`StartRecording()`方法开始录音,使用Unity的`Microphone`类
- 设置16000Hz采样率(符合讯飞要求)
- 使用10秒音频缓冲区
2. **音频数据处理**:
- `StopRecordingAndSend()`停止录音并发送数据
- `SendAudioFrames()`将音频数据转换为16位PCM格式
- 分帧发送(每帧640字节=320ms音频)
- 实时发送:在Update()中持续发送录音数据
3. **数据帧格式**:
- 音频帧:status=1 + base64编码的PCM数据
- 结束帧:status=2 + 空数据
- 所有帧都包含会话ID(sid)
4. **结果解析**:
- 添加了JSON解析结构`ISEResponse`和`ISEData`
- 解析服务器返回的XML格式结果
- 提取总分示例(实际应用需完整解析XML)
5. **音频格式转换**:
- 将Unity的float音频数据(-1.0~1.0)转换为16位PCM
- 符合讯飞要求的L16格式
### 使用说明:
1. 将此脚本附加到Unity场景中的GameObject
2. 调用`StartRecording()`开始录音
3. 调用`StopRecordingAndSend()`停止录音并发送数据
4. 评测结果将在控制台输出(XML格式)
### 注意事项:
1. 确保麦克风权限已开启
2. 实际使用时需要处理网络错误和超时
3. 完整的XML解析需要根据讯飞文档实现
4. 在移动设备上测试时注意性能问题