讯飞语音转文本:定位阅读进度与高亮文本的技术实现
前言
在现代应用中,语音转文本技术已成为提升用户体验的关键技术。本文将深入探讨如何基于讯飞语音API实现精准的语音转文本功能,并通过AudioTextMapping
数据表实现阅读进度定位和文本高亮显示。
技术架构概览
核心组件
- 讯飞语音转文本服务 - 负责音频识别和文本转换
- AudioTextMapping数据表 - 存储音频与文本的映射关系
- 流式TTS时间戳处理 - 实现精确的音频文本同步
- 前端高亮显示 - 基于时间戳的实时文本高亮
数据流程图
AudioTextMapping数据表设计
表结构定义
csharp
/// <summary>
/// 音频文本映射表,用于记录音频位置和文本的关系
/// </summary>
public class AudioTextMapping
{
/// <summary>
/// 主键ID
/// </summary>
[Key]
public int Id { get; set; }
/// <summary>
/// 故事ID
/// </summary>
[Required]
public int StoryId { get; set; }
/// <summary>
/// 关联的配音ID
/// </summary>
public Guid? StoryVoiceId { get; set; }
/// <summary>
/// 文本内容
/// </summary>
[Required]
public string TextContent { get; set; }
/// <summary>
/// 文本开始位置(字节)
/// </summary>
[Required]
public int TextStartByte { get; set; }
/// <summary>
/// 文本结束位置(字节)
/// </summary>
[Required]
public int TextEndByte { get; set; }
/// <summary>
/// 音频URL或Base64编码
/// </summary>
public string AudioData { get; set; }
/// <summary>
/// 音频开始时间(秒)
/// </summary>
[Column(TypeName = "decimal(18,10)")]
public decimal? AudioStartTime { get; set; }
/// <summary>
/// 音频结束时间(秒)
/// </summary>
[Column(TypeName = "decimal(18,10)")]
public decimal? AudioEndTime { get; set; }
/// <summary>
/// 合成进度,当前合成文本的字节数
/// </summary>
public int? Ced { get; set; }
/// <summary>
/// 会话ID
/// </summary>
public string Sid { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
关键字段说明
- TextStartByte/TextEndByte: 文本在完整内容中的字节位置,用于精确定位文本段落
- AudioStartTime/AudioEndTime: 对应音频片段的时间范围(秒),支持10位小数精度的精确时间同步
- Ced: 讯飞TTS返回的合成进度,表示当前合成文本的字节数,用于精确映射
- Sid: 讯飞会话ID,用于标识同一次TTS合成会话
- AudioData: 音频URL或Base64编码的音频数据
- StoryVoiceId: 关联的配音ID,支持多种配音版本
AudioStartTime 和 AudioEndTime 计算方式详解
精准计算每个分段音频的 AudioStartTime
和 AudioEndTime
是实现文本同步高亮的核心。技术实现遵循以下流程:
1. 解析讯飞流式响应数据 (ced
与 audio
)
当向讯飞语音合成服务请求流式TTS时,服务会通过WebSocket返回一系列JSON消息。每个消息代表一个处理片段,核心数据是 ced
和 audio
。
-
数据结构示例:
json// 单条消息示例 { "code": 0, "message": "success", "sid": "tts000f1d29@dx1773a0b8d140000100", "data": { "status": 1, // 1表示合成中,2表示合成结束 "ced": "15", // Character End aea: 已处理的文本字节数 "audio": "... (Base64编码的MP3音频数据) ..." } }
-
关键字段解析:
ced
: 表示到当前这个数据帧为止,讯飞引擎已经处理了原始文本中的多少个字节。这是一个累积值。audio
: Base64编码的MP3音频数据片段,对应本次ced
增量所覆盖的文本内容。
-
处理流程 :后端服务在请求过程中会收集所有返回的JSON消息。TTS结束后,
ProcessIFlytekTimestampsBatchAsync
方法会遍历这些消息,通过ced
的变化来切分文本,并将每个文本片段与对应的audio
数据关联起来,形成一个个独立的"分段"。
2. 从文件头获取比特率
对于每个分段的 audio
数据(解码后的MP3字节流),系统会读取其文件头部信息。通过解析MP3的帧同步字(Frame Sync)及相关元数据,可以直接确定该音频的比特率(Bitrate)。
3. 根据比特率和字节长度计算时长
获取到精确的比特率后,结合音频分段解码后的真实字节长度,使用以下核心公式计算每个分段的播放时长。为保证精度,计算全程采用 decimal
高精度类型。
-
核心公式:
csharp// segment.Audio 是Base64编码的音频字符串 var decodedAudioBytes = Convert.FromBase64String(segment.Audio); // 应用公式:时长(秒) = (字节数 * 8) / 比特率(bps) decimal segmentDuration = (decimal)decodedAudioBytes.Length * 8 / bitRate;
-
字节长度说明 :公式中的字节长度是Base64字符串解码后的原始二进制数据长度,通过
Convert.FromBase64String(base64String).Length
获取,这避免了因Base64编码本身带来的体积膨胀(约33%)所导致的计算错误。
4. 时间戳的累积生成
在计算出每个分段的精确时长后,系统通过累积的方式生成时间戳:
- 第一个分段的
AudioStartTime
初始化为0
。 - 后续每个分段的
AudioStartTime
是其前面所有分段segmentDuration
的总和。 - 每个分段的
AudioEndTime
则等于其自身的AudioStartTime + segmentDuration
。
讯飞语音转文本实现
1. 语音识别服务
csharp
public class IFlytekSpeechToTextProvider : ISpeechToTextProvider
{
private readonly string _appId;
private readonly string _apiKey;
private readonly string _apiSecret;
public async Task<SpeechToTextResponseDto> ConvertSpeechToTextAsync(
SpeechToTextRequestDto request)
{
// 处理音频格式
var processedAudio = ProcessAudioFormat(request.AudioData, request.AudioFormat);
// 生成WebSocket连接URL
var url = GenerateWebSocketUrl("/v2/iat");
using var webSocket = new ClientWebSocket();
await webSocket.ConnectAsync(new Uri(url), CancellationToken.None);
// 发送音频数据并接收识别结果
var result = await ProcessWebSocketRecognition(webSocket, processedAudio);
return result;
}
private string GenerateWebSocketUrl(string path)
{
var host = "iat-api.xfyun.cn";
var date = DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'");
// 构建签名
var signatureOrigin = $"host: {host}\ndate: {date}\nGET {path} HTTP/1.1";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_apiSecret));
var signature = Convert.ToBase64String(
hmac.ComputeHash(Encoding.UTF8.GetBytes(signatureOrigin)));
// 构建认证头
var authorizationOrigin =
$"api_key=\"{_apiKey}\", algorithm=\"hmac-sha256\", " +
$"headers=\"host date request-line\", signature=\"{signature}\"";
var authorization = Convert.ToBase64String(
Encoding.UTF8.GetBytes(authorizationOrigin));
return $"wss://{host}{path}?authorization={authorization}&date={Uri.EscapeDataString(date)}&host={host}";
}
}
2. 流式TTS时间戳处理
csharp
public async Task<List<AudioTextMapping>> ProcessIFlytekTimestampsBatchAsync(
int storyId,
List<string> timestampResponses,
string fullText,
Guid? storyVoiceId = null)
{
var mappings = new List<AudioTextMapping>();
var cedDataList = new List<(int ced, string audioData, string sid)>();
// 解析所有时间戳响应
foreach (var timestampResponse in timestampResponses)
{
var response = JsonSerializer.Deserialize<JsonElement>(timestampResponse);
if (response.TryGetProperty("data", out var dataElement))
{
// 提取CED值和音频数据
if (dataElement.TryGetProperty("ced", out var cedElement) &&
dataElement.TryGetProperty("audio", out var audioElement))
{
if (int.TryParse(cedElement.GetString(), out int cedValue))
{
var audioData = audioElement.GetString();
var sid = response.TryGetProperty("sid", out var sidElement)
? sidElement.GetString() : string.Empty;
cedDataList.Add((cedValue, audioData, sid));
}
}
}
}
// 按CED值排序并创建映射记录
cedDataList.Sort((a, b) => a.ced.CompareTo(b.ced));
var fullTextBytes = Encoding.UTF8.GetBytes(fullText);
var previousCed = 0;
foreach (var (ced, audioData, sid) in cedDataList)
{
// 计算文本范围
var startByte = previousCed;
var endByte = ced;
if (startByte < endByte && endByte <= fullTextBytes.Length)
{
// 提取对应文本段落
var textSegmentBytes = new byte[endByte - startByte];
Array.Copy(fullTextBytes, startByte, textSegmentBytes, 0, textSegmentBytes.Length);
var textSegment = Encoding.UTF8.GetString(textSegmentBytes);
// 创建映射记录
var mapping = new AudioTextMapping
{
StoryId = storyId,
StoryVoiceId = storyVoiceId,
TextContent = textSegment,
TextStartByte = startByte,
TextEndByte = endByte,
AudioData = audioData,
Ced = ced,
Sid = sid,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
mappings.Add(mapping);
}
previousCed = ced;
}
// 批量保存到数据库
if (mappings.Count > 0)
{
_context.AudioTextMappings.AddRange(mappings);
await _context.SaveChangesAsync();
}
return mappings;
}