第二章|# StorySpeak AI儿童历史文化故事智能讲解平台:前端录音、后端接入讯飞星火、语音转文本与文本转语音统一接口

目标:聚焦语音交互的端到端实现,提供架构要点与可复用的核心代码,便于快速集成到你的项目。

一、从用户场景出发:为什么需要“语音问答”?

  • 场景:孩子听到“司马光砸缸”故事后,追问“他为什么砸缸?”、“还能用别的方法吗?”
  • 痛点:传统音频故事是单向播放,孩子的问题得不到回应;家长也难以持续陪伴解释。
  • 目标:让孩子可以用语音直接提问,系统用更符合年龄的语言即时回答,并能继续追问。

——

二、方案总览:一次语音问答的端到端路径

  • 前端录音与编码:采集音频,转成 Base64 或分片流式发送,带上语言、用户、会话参数。
  • 后端接收与校验:落盘或暂存,校验时长与格式,创建语音交互记录用于追踪。
  • 接入讯飞星火:建立会话,发送配置与音频,流式接收识别与生成结果。
  • 语音转文本与文本转语音统一服务:统一接口,可切换提供商与缓存复用。
  • 写入交互记录:把输入/输出音频、文本、质量评分与错误信息完整落库,形成闭环。

架构示意:

孩子提问
前端录音编码
后端语音接口
星火会话
识别
合成
交互记录

——

三、关键实现

  1. 前端录音与编码
  • 要点:16k 采样率、单声道、稳定的录音时长控制;支持断点续传或分片以降低丢包风险。
  • 做法:录音结束得到 ArrayBuffer/Blob,转 Base64;在请求中携带语言、用户ID、会话ID等元信息。
  1. 后端接收与校验
  • 要点:限制最大时长与文件大小;校验格式并统一转码;创建交互记录以便后续追踪。
  • 做法:收到音频后先存储临时文件,生成 InputAudioFileName/Path/Url 等字段,并记录 InputAudioDuration/Size。
  1. 发起星火会话
  • 要点:按会话配置(语言、角色、参数)建立连接;流式推送音频;订阅中间/最终结果;异常重试与降级。
  • 做法:发送会话配置后按块推送音频,收到识别文本与生成回答后,合并为结构化结果返回前端。
  1. 语音转文本与文本转语音统一接口与缓存
  • 要点:用统一服务封装语音转文本与文本转语音;对重复文本合成结果做缓存;对失败场景统一抛异常并记录。
  • 做法:按 provider 路由到对应实现,返回统一 DTO;缓存命中后直接回传,减少时延与成本。
  1. 写入交互记录
  • 要点:把 InputText/OutputText、输入/输出音频的文件名、路径、URL、格式、时长、大小、质量评分全部写入;错误信息与处理元数据也保留。
  • 做法:会话开始创建记录,输入音频保存后更新;识别/合成完成再补充输出数据与评分,最终设置 CompletedAt。

——

四、易踩坑与修复经验

  • 音频格式不兼容:采样率必须 16k(或按服务要求),单声道;不规范会导致识别不稳定或失败。
  • 分片顺序错乱:网络抖动时要确保片段顺序与完整性;必要时引入序号与校验。
  • 长连接超时与重连:心跳与超时统一管理,短故障重试,长故障降级为离线识别或提示稍后再试。
  • 文本对齐与时间戳:识别返回的文本需要与音频时间轴对齐,便于高亮显示与进度定位。
  • OSS/CDN 链接有效期:音频 URL 需要刷新策略或使用永久路径;避免回放失败。
  • 安全与限流:鉴权密钥安全存储;接口限流防止滥用;错误分级便于监控与告警。

——

五、性能与体验优化建议

  • 流式优先:长文本/长音频场景采用流式识别与流式合成,显著降低首帧时延。
  • 分片与批处理:网络拥塞时分片传输,服务端合并;批量合成减少排队与等待。
  • 缓存复用:常用回答或固定文本的 TTS 结果缓存命中;节省成本与时间。
  • 文件命名与路径规范:统一前缀、时间戳、用户/故事维度;便于审计与追踪。
  • 监控与追踪:日志与链路追踪结合,定位错误与性能瓶颈更快。

——

六、这套方案带来的实际效果

  • 交互闭环:每次问答都有记录,可复盘问题、回答与音频质量;便于后续教研分析。
  • 时延可控:采用流式后,首帧响应显著缩短;缓存命中时,从数秒降到数百毫秒级。
  • 稳定性提升:统一的重试与降级策略,弱网下更可靠;错误信息结构化,便于定位与修复。

——

附录|关键代码与算法示例

  1. 前端:录音与上传

目的:将录音后的音频数据编码并上传后端,用于开始一次语音问答。
适用场景:短语音提问,弱网环境建议分片上传。
输入:录音得到的 Blob;用户ID、语言、会话ID等元信息。
输出:后端返回交互记录ID(后续用来查询状态与结果)。
常见错误与提示:采样率需为16k、单声道;Base64长度较长时可分片发送;接口失败要做重试与错误提示。

async function uploadVoiceQuestion(blob: Blob, opts: {
  userId: string;
  language: 'zh-CN' | 'en-US';
  sessionId: string;
}) {
  const buf = new Uint8Array(await blob.arrayBuffer());
  const base64 = btoa(String.fromCharCode(...buf));
  const res = await fetch('/api/voice/save-audio-chat-interaction', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      userId: opts.userId,
      language: opts.language,
      sessionId: opts.sessionId,
      audioBase64: base64,
      format: 'wav',
      sampleRate: 16000,
    }),
  });
  return res.json();
}
  1. 后端:保存音频并创建交互记录

目的:接收前端的音频与元信息,落盘/存储后创建一次交互记录,返回记录ID。
输入:SaveAudioDto(包含Base64、格式、采样率、用户与会话信息)。
输出:交互记录ID,用于后续启动会话与查询结果。
常见错误与提示:校验采样率与格式;限制最大时长与大小;出现异常时返回明确的错误码与信息。

public async Task<Guid> SaveAudioInteractionCore(
    SaveAudioDto dto,
    IVoiceInteractionService interactionService,
    ISparkVoiceInteractionService sparkService)
{
    var record = await interactionService.CreateInteractionRecordAsync(new CreateInteractionOptions
    {
        UserId = dto.UserId,
        Language = dto.Language,
        InteractionType = VoiceInteractionType.AudioQA,
    });

    await interactionService.UpdateInteractionRecordWithInputAudioAsync(record.Id, new InputAudioInfo
    {
        Base64 = dto.AudioBase64,
        Format = dto.Format,
        SampleRate = dto.SampleRate,
    });

    var result = await sparkService.StartAsync(new InteractionStartOptions
    {
        Config = new SparkSessionConfig { Language = dto.Language },
        AudioChunks = AudioChunker.FromBase64(dto.AudioBase64, chunkSize: 64 * 1024),
    });

    await interactionService.UpdateInteractionRecordWithOutputAsync(record.Id, new OutputInfo
    {
        AsrText = result.AsrText,
        AnswerText = result.AnswerText,
        OutputAudioUrl = result.AudioUrl,
        QualityScore = result.QualityScore,
        Error = result.Error,
    });

    return record.Id;
}
  1. 星火会话管理与流式处理

目的:与星火语音服务建立会话,流式发送音频并接收识别文本与生成回答的增量消息。
输入:会话配置(语言、角色、参数)与音频分片。
输出:合并后的识别文本与回答文本;必要时包含错误信息与重试次数。
常见错误与提示:网络抖动导致分片丢失需重试;超时要心跳保活或自动重连;异常降级为离线识别。

public class SparkVoiceInteractionService : ISparkVoiceInteractionService
{
    private readonly SparkVoiceConnectionManager _conn;

    public SparkVoiceInteractionService(SparkVoiceConnectionManager conn)
    {
        _conn = conn;
    }

    public async Task<InteractionResult> StartAsync(InteractionStartOptions opt)
    {
        using var session = await _conn.CreateSessionAsync(opt.Config);

        // 发送会话配置(角色、语言、参数)
        await session.SendConfigAsync(opt.Config);

        // 流式推送音频(或一次性 Base64)
        foreach (var chunk in opt.AudioChunks)
        {
            await session.SendAudioChunkAsync(chunk);
        }

        // 接收识别与生成的增量结果
        var result = new InteractionResult();
        await foreach (var msg in session.ReceiveAsync())
        {
            if (msg.Type == SparkMessageType.PartialASR)
                result.AppendAsr(msg.Text);
            else if (msg.Type == SparkMessageType.PartialAnswer)
                result.AppendAnswer(msg.Text);
        }

        await session.CloseAsync();
        return result;
    }
}
  1. 语音转文本与文本转语音统一接口

目的:为不同语音提供商(如讯飞、阿里等)提供统一调用入口,便于切换与扩展。
输入:AudioInput(音频数据)、SttOptions/TtsOptions(语言、提供商、音色等)。
输出:识别结果(文本与置信度)、合成结果(音频URL或字节流)。
常见错误与提示:统一异常类型,便于在控制器与前端做一致的错误处理与提示。

public interface IVoiceService
{
    Task<SpeechToTextResult> SpeechToTextAsync(AudioInput audio, SttOptions options);
    Task<TextToSpeechResult> TextToSpeechAsync(string text, TtsOptions options);
    Task<AssessmentResult> AssessPronunciationAsync(AudioInput audio, string referenceText, AssessmentOptions options);
}

public record AudioInput(byte[] Data, string Format, int SampleRate);
public record SttOptions(string Language, string Provider);
public record TtsOptions(string Language, string Provider, string VoiceName);
  1. 缓存策略示例

目的:对常用文本的TTS结果做缓存命中,降低合成时延与成本。
输入:文本与TTS选项;输出:已缓存的音频URL或空。
常见错误与提示:注意缓存键的稳定性与过期策略;大文本建议分段缓存。

public class VoiceCacheService
{
    private readonly IStorage _storage;
    public async Task<string?> GetTtsUrlAsync(string text, TtsOptions opt)
    {
        var key = $"tts:{opt.Provider}:{opt.VoiceName}:{opt.Language}:{text.GetHashCode()}";
        return await _storage.GetUrlByKeyAsync(key);
    }
    public async Task SetTtsUrlAsync(string text, TtsOptions opt, string url)
    {
        var key = $"tts:{opt.Provider}:{opt.VoiceName}:{opt.Language}:{text.GetHashCode()}";
        await _storage.SetUrlByKeyAsync(key, url, TimeSpan.FromDays(7));
    }
}
  1. 流式处理与重试算法

目的:保障弱网情况下的音频分片传输稳定性,并在失败时做指数退避重试与降级。
输入:音频分片;输出:合并后的识别与回答结果或明确的错误信息。
常见错误与提示:重试上限与退避参数需根据真实网络状况调优;降级策略要对用户友好。

public record SessionConfig(int RetryMax = 3, int RetryBaseDelayMs = 200, bool AllowOfflineFallback = false);
public record InteractionResult(string AsrText, string AnswerText);

public class InteractionService
{
    /// <summary>
    /// 核心流程:初始化会话 → 发送配置 → 按顺序发送音频分片并对网络错误做指数退避重试 → 聚合流式结果 → 关闭会话 → 返回最终文本。
    /// 说明侧重可运维性:约束、失败路径与扩展点,而非框架细节。
    /// </summary>
    /// <param name="audioChunks">按时间顺序的音频分片;建议 100–400ms/片,单声道 16k PCM,保持顺序且避免枚举器被重复遍历。</param>
    /// <param name="cfg">重试与降级参数;RetryMax 建议 3–5,BaseDelay 建议 200–300ms,生产环境加入抖动(jitter)并设最大延迟上限。</param>
    /// <param name="ct">取消令牌;用于快速中止长尾重试与接收循环,不要吞掉取消异常。</param>
    /// <returns>聚合后的 ASR 与回答文本;降级或中止时可能为空字符串。</returns>
    public async Task<InteractionResult> StreamInteractionAsync(IEnumerable<byte[]> audioChunks, SessionConfig cfg, CancellationToken ct)
    {
        // 1) 初始化会话并发送会话配置(语言、角色、参数等)
        var session = await InitSessionAsync(ct);
        await session.SendConfigAsync(ct);

        // 2) 分片发送音频:弱网下对网络异常做指数退避重试
        foreach (var chunk in audioChunks)
        {
            var attempt = 0;
            while (true)
            {
                try
                {
                    await session.SendAudioChunkAsync(chunk, ct); // 正常发送当前分片
                    break;
                }
                catch (Exception ex) when (IsNetworkError(ex) && attempt < cfg.RetryMax)
                {
                    attempt++;
                    var delayMs = cfg.RetryBaseDelayMs * (int)Math.Pow(2, attempt); // 指数退避:200→400→800ms...
                    await Task.Delay(delayMs, ct);
                }
                catch (Exception ex) when (cfg.AllowOfflineFallback)
                {
                    // 3) 降级:切换离线 STT/TTS 或直接中止
                    return await OfflineFallbackAsync(audioChunks, ct);
                }
            }
        }

        // 4) 流式接收并聚合:将识别与回答逐步拼接为完整文本
        var asr = new StringBuilder();
        var answer = new StringBuilder();
        await foreach (var msg in session.ReceiveMessagesAsync(ct))
        {
            if (msg.Type == MessageType.PartialASR) asr.Append(msg.Text);       // 识别中间结果累加
            else if (msg.Type == MessageType.PartialAnswer) answer.Append(msg.Text); // 回答中间结果累加
        }

        // 5) 关闭会话并返回聚合后的最终结果
        await session.CloseAsync(ct);
        return new InteractionResult(asr.ToString(), answer.ToString());
    }

    // 注意:以下辅助方法仅为示例接口,生产环境请替换为真实实现(网络错误判定、会话初始化、离线降级)。
    private static bool IsNetworkError(Exception ex) => ex is IOException || ex is TimeoutException;
    private Task<object> InitSessionAsync(CancellationToken ct) => Task.FromResult(new object());
    private Task<InteractionResult> OfflineFallbackAsync(IEnumerable<byte[]> chunks, CancellationToken ct) => Task.FromResult(new InteractionResult("", ""));
}

——

八、总结与下一章预告

  • 总结:语音问答的关键不在于“堆技术”,而是围绕用户问题设计一条稳定、低时延、可追踪的链路。我们通过会话管理、语音转文本与文本转语音统一接口与交互记录闭环,兼顾了体验与工程可维护性。
  • 预告:下一章将把发音评估与语音问答系统打通,讲清楚如何在孩子练习过程中给出即时纠错与奖励机制。

——

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐