☰
基于NAudio的C#网络收音机源码:流播放与ICY元数据解析
2026/11/5 3:09:29 网站建设 项目流程

简介:《网络收音机源代码》是一份面向C#初学者与多媒体开发爱好者的实战项目包,围绕接入网络电台、读取音频流并完成播放这一目标,展示Socket通信、音频解码播放、事件驱动与Windows Forms界面设计等典型实现,适合用来理解网络编程和桌面音频应用的基本结构。压缩包共34个文件,约334KB,包含6个cs源码、9个dll依赖库、3个exe程序及resx、resources等界面与配置资源,另带解决方案和项目文件,便于直接打开分析。已有702人学习,读者可从中梳理电台地址管理、播放控制、列表与音量调节等模块的实现思路,并参考配置化存储、电台更新、搜索分类等改造方向,作为课程设计或小型桌面应用的参考原型。

1. 从一个“只想听个响”的需求说起

想在桌面小工具里塞一个能听电台的按钮,翻了一圈现成播放器,不是要登录就是塞满推广位,最后决定自己写。这份网络收音机 C# 源代码就是那类“刚好够用”的实现:一份 JSON 电台清单、一个基于 NAudio 的播放内核、一条能解析 ICY 元数据、把当前节目名推到标题栏的链路,全部堆在 WinForms 里,没有浏览器内核,也没有第三方播放器控件。它适合两类人:一类是刚学完 C# 语法想找个能出声音的练手项目的新手,另一类是要在自己的桌面工具里内嵌电台功能、不想被现成播放器限制的开发者。代码量不大,但网络流、音频解码、元数据解析这三块该有的坑一个不少,拿来当网络编程和解码管线的入门样本刚好。

2. 技术选型与整体架构:四层拆开看这个播放器怎么转

写音频程序最怕一上来就堆代码,播不出声的时候完全不知道是哪一层出了问题。这份源码的分层不算精致,但边界划得比较清楚,调试的时候能一层一层往下压。先把选型讲明白,再谈结构。

2.1 为什么是 WinForms 加 NAudio,而不是内嵌播放器控件

很多 C# 电台 demo 图省事,直接拖一个 Windows Media Player 的 COM 控件上去,两行代码就能出声。这种做法在纯播放场景下没毛病,但一旦要做三件事就立刻失效:一是拿 PCM 数据做录音或者频谱,二是自己控制缓冲和重连逻辑,三是把当前曲目信息解析出来显示。COM 控件是个黑匣子,你只能给它一个 URL,剩下什么都插不进去。

这份源码选了另一条路:界面用 WinForms(老项目常见 .NET Framework 4.8,新一点的移植到 .NET 8 加UseWindowsForms),播放内核用 NAudio 的BufferedWaveProvider加WaveOutEvent,解码用Mp3Frame配合 ACM 解码器。整条链路是“自己拉流、自己剥元数据、自己解成 PCM、自己往声卡喂”,每一环都能打断点。依赖清单很短:

依赖用途备注
NAudioMP3 帧解析、PCM 缓冲、声卡输出核心,绕不开
Newtonsoft.Json电台清单和配置读写老项目居多;新项目换 System.Text.Json
HttpClient(BCL)拉取网络流不要用 WebClient,超时和取消不好控

常见做法是把这个项目做成单 exe 加一个 assets 目录,电台清单跟着程序走,用户改过的部分写回 AppData,卸载重装不丢数据。这个设计在后面讲持久化的时候会具体展开。

2.2 四层的职责边界:UI 只负责显示,播放器只管出声

把源码目录摊开,其实是四块:表现层RadioNet.App(MainForm、电台编辑对话框)、播放内核RadioNet.Core(RadioPlayer、IcyStreamReader、解码工厂)、数据层RadioNet.Data(StationRepository、AppSettings)、测试RadioNet.Core.Tests。

调用链是一条直线,没有绕回去的依赖:MainForm 点“播放”→ 交给 RadioPlayer.PlayAsync → 内部用 HttpClient 拿流 → 包一层 IcyStreamReader 剥元数据 → 解码器出 PCM → 写进 BufferedWaveProvider → WaveOutEvent 出声。元数据反向走事件:IcyStreamReader 解析出StreamTitle抛MetadataParsed,RadioPlayer 转发成MetadataChanged,MainForm 在事件里改 Label 文字。

这里有个容易忽略的细节:事件回调发生在拉流的后台线程上,直接改控件会抛跨线程异常。源码里的处理是if (label.InvokeRequired) label.BeginInvoke(...)。我见过不少人在这一行翻车,播放正常,一切换电台就崩,报的还是一句看不懂的“线程间操作无效”。

2.3 播放状态机:把“正在缓冲”和“已经断了”分开

状态机是这份源码里最值得抄的部分。它定义了五个状态:Idle、Connecting、Buffering、Playing、Reconnecting,加一个异常出口Faulted。所有 UI 上的按钮禁用、转圈动画、错误提示都挂在这个状态上,而不是散落在各个 if 里。

为什么要单独区分Buffering和Connecting?因为用户体感完全不同。Connecting是还没握上手,超过 15 秒就该报“地址无响应”;Buffering是已经连上了但缓冲区水位不够,这时候界面要显示“缓冲中”而不是报错,否则网络抖一下用户就以为坏了。切换逻辑放在一个SetState私有方法里,统一做三件事:更新字段、记日志、抛StateChanged事件。所有状态变更都走这一个口子,调试时只在这个方法里打断点,就能看完整条时间线,不用满项目搜赋值。

3. 目录结构与核心模块:一条流地址到扬声器出声的完整链路

拿到源码包先别急着按 F5。花十分钟把目录和核心类看一遍,后面报错的时候能直接定位到文件。这一章按数据流向拆四个关键模块:目录、播放器、ICY 解析、清单持久化。

3.1 源码目录长什么样

典型的解压后结构是这样,如果你手上的包略有出入,按类名对照即可:

RadioNet/ ├─ RadioNet.sln ├─ src/ │ ├─ RadioNet.App/ WinForms 界面层 │ │ ├─ MainForm.cs 主窗口:播放/停止/音量/标题 │ │ ├─ MainForm.Designer.cs │ │ └─ StationEditDialog.cs 手动添加电台地址 │ ├─ RadioNet.Core/ 播放内核 │ │ ├─ RadioPlayer.cs 拉流、解码、喂缓冲、重连 │ │ ├─ IcyStreamReader.cs 剥离 ICY 元数据的流包装 │ │ ├─ StreamMetadata.cs StreamTitle 解析 │ │ ├─ DecoderFactory.cs 按 codec 字段选解码器 │ │ └─ PlaybackState.cs 状态枚举 │ └─ RadioNet.Data/ 数据层 │ ├─ RadioStation.cs 电台实体 │ ├─ StationRepository.cs 清单加载与导入 │ └─ AppSettings.cs 音量、上次电台、超时 ├─ assets/stations.json 内置电台清单 └─ tests/RadioNet.Core.Tests/ 解析和元数据的单元测试

RadioNet.Core里没有任何using System.Windows.Forms,这是刻意的。内核不依赖界面,才能被控制台程序、服务甚至单元测试直接引用。二次开发想加个命令行版本,直接引这个项目就行。

3.2 RadioPlayer:拉流、解码、喂缓冲的主循环

整个项目的心脏是RadioPlayer。它构造时做三件事:建一个长期存活的HttpClient、建一个BufferedWaveProvider作缓冲、建一个WaveOutEvent作输出。

// RadioNet.Core/RadioPlayer.cs public sealed class RadioPlayer : IDisposable { private readonly HttpClient _http; // 全局复用,别在循环里 new,端口会耗尽 private readonly WaveOutEvent _output; // 声卡输出 private readonly BufferedWaveProvider _buffer; // PCM 缓冲区,用来扛网络抖动 private CancellationTokenSource _cts; // 水位线:高于上限就先停下来等,低于下限说明该加速拉流 private const int HighWaterBytes = 256 * 1024; private const int LowWaterBytes = 32 * 1024; public event EventHandler<PlaybackState> StateChanged; public event EventHandler<StreamMetadata> MetadataChanged; public RadioPlayer() { _http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; _buffer = new BufferedWaveProvider(new WaveFormat(44100, 16, 2)) { DiscardOnBufferOverflow = true, // 突发时丢旧数据,别让延迟越滚越大 BufferDuration = TimeSpan.FromSeconds(30) }; _output = new WaveOutEvent { DesiredLatency = 200, NumberOfBuffers = 3 }; _output.Init(_buffer); } }

DiscardOnBufferOverflow这个开关值得单说。默认是 false,缓冲区满了以后AddSamples会直接抛异常,程序崩给你看。设成 true 之后,满了就丢最旧的数据。听电台场景下丢几百毫秒旧音频完全无感,但延迟不断累积就很致命——你会发现声音比“实时”慢了半分钟,切台的时候还在播上一个台的尾巴。

真正干活的是PlayAsync和它的拉流循环:

public async Task PlayAsync(RadioStation station, CancellationToken token = default) { await StopAsync(); _cts = CancellationTokenSource.CreateLinkedTokenSource(token); SetState(PlaybackState.Connecting); var request = new HttpRequestMessage(HttpMethod.Get, station.Url); // 关键请求头:告诉服务端"我要节目名元数据" request.Headers.TryAddWithoutValidation("Icy-MetaData", "1"); request.Headers.TryAddWithoutValidation("User-Agent", "RadioNet/1.0"); // ResponseHeadersRead:拿到响应头就返回,不要等整个流下载完(流是无限的) var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cts.Token); response.EnsureSuccessStatusCode(); // 服务端回一个 icy-metaint,表示每多少字节音频夹一段元数据 int metaInt = 0; if (response.Headers.TryGetValues("icy-metaint", out var values)) int.TryParse(values.FirstOrDefault(), out metaInt); var raw = await response.Content.ReadAsStreamAsync(); var icy = new IcyStreamReader(raw, metaInt); icy.MetadataParsed += (s, m) => MetadataChanged?.Invoke(this, m); SetState(PlaybackState.Buffering); _output.Play(); await PumpAsync(icy, station.Codec, _cts.Token); }

HttpCompletionOption.ResponseHeadersRead这一行是个分水岭。用默认值的话,SendAsync会一直等到响应体读完才返回,而电台流永远不会结束,所以你永远等不到返回。新手卡在这里的典型症状是:点了播放按钮,转圈转到天荒地老,日志里连“已连接”都不打。

PumpAsync是解码主循环:

private async Task PumpAsync(Stream icy, string codec, CancellationToken ct) { using var decoder = DecoderFactory.Create(codec); // mp3 走 ACM,aac 走 MediaFoundation int retry = 0; while (!ct.IsCancellationRequested) { try { var frame = Mp3Frame.LoadFromStream(icy); if (frame == null) throw new EndOfStreamException("服务端关闭了流"); var pcm = decoder.DecompressFrame(frame, out int count); _buffer.AddSamples(pcm, 0, count); if (_buffer.BufferedBytes > LowWaterBytes && retry == 0) SetState(PlaybackState.Playing); // 水位控制:超上限就等一会,防止内存里堆几十兆音频 while (_buffer.BufferedBytes > HighWaterBytes && !ct.IsCancellationRequested) await Task.Delay(50, ct); } catch (Exception ex) when (ex is IOException || ex is EndOfStreamException) { if (++retry > 5) { SetState(PlaybackState.Faulted); throw; } SetState(PlaybackState.Reconnecting); // 指数退避:1、2、4、8、16 秒,别拿死循环去打人家服务器 await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 1 << retry)), ct); icy = await ReconnectAsync(ct); } } }

指数退避是必须的。有些流地址在服务端负载高的时候会主动断开,客户端立刻重连会形成雪崩,越重连越连不上。Math.Min(30, 1 << retry)给了一个上限,五次失败后进Faulted状态,界面上给用户一个“重试”按钮,比后台无限重连体验好得多。

3.3 IcyStreamReader:把元数据从音频流里剥出来

ICY 协议的做法很朴素:服务端在响应头里给你一个icy-metaint,比如 8192,意思就是“前 8192 字节是纯音频,第 8193 字节是个长度字节 N,紧接着 N×16 字节的元数据块,之后又是 8192 字节音频,如此循环”。所以你得自己数着字节走,把元数据块吃掉,让下游的解码器只看到干净音频。

// RadioNet.Core/IcyStreamReader.cs public sealed class IcyStreamReader : Stream { private readonly Stream _inner; private readonly int _metaInt; // 音频段长度 private int _bytesUntilMeta; // 距离下一段元数据还剩多少字节 private readonly byte[] _metaBuffer = new byte[4080]; // 255 * 16,协议规定的上限 public event EventHandler<StreamMetadata> MetadataParsed; public override int Read(byte[] buffer, int offset, int count) { if (_metaInt <= 0) return _inner.Read(buffer, offset, count); // 不带元数据的流,透明转发 if (_bytesUntilMeta <= 0) { ReadMetadataBlock(); // 先把上一段元数据消费掉 _bytesUntilMeta = _metaInt; } int toRead = Math.Min(count, _bytesUntilMeta); // 绝不越过元数据边界 int read = _inner.Read(buffer, offset, toRead); _bytesUntilMeta -= read; return read; } private void ReadMetadataBlock() { int lenByte = _inner.ReadByte(); if (lenByte <= 0) return; // 0 表示这轮没有新标题 int length = lenByte * 16; int got = 0; while (got < length) { int n = _inner.Read(_metaBuffer, got, length - got); if (n <= 0) return; // 流提前结束,别写成死循环 got += n; } var text = Encoding.UTF8.GetString(_metaBuffer, 0, length).TrimEnd('\0'); MetadataParsed?.Invoke(this, StreamMetadata.Parse(text)); } }

继承Stream只需要实现Read,其余方法大部分可以抛NotSupportedException,但CanRead要返回 true,否则 NAudio 那边会直接拒绝。这里最容易写的 bug 是Read一次性读到元数据区里去——Math.Min(count, _bytesUntilMeta)这一行就是防线,删掉它,症状是音频里夹着“咔”的杂音,因为解码器把元数据当成了 MP3 帧头。

StreamMetadata.Parse干的事情不复杂,元数据串长这样:StreamTitle='频道名 - 节目名';StreamUrl='';。用正则把单引号里的内容抠出来就够了,注意标题里本身可能带单引号,协议里约定用两个连续单引号转义,解析完再还原回去。

3.4 电台清单与配置:脏数据要在入口拦住

清单是一份 JSON,每一项包含id、name、genre、url、codec、bitrate六个字段:

[ { "id": "demo-classic", "name": "示例电台 · 古典", "genre": "Classical", "url": "http://stream.local:8000/classic.mp3", "codec": "mp3", "bitrate": 128 } ]

加载逻辑有个细节:优先读 AppData 下用户改过的那份,没有才读程序目录里的内置清单。这样升级程序覆盖安装时,用户自己加的台不会被冲掉。

public IReadOnlyList<RadioStation> Load() { var userFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RadioNet", "stations.json"); var path = File.Exists(userFile) ? userFile : Path.Combine(AppContext.BaseDirectory, "assets", "stations.json"); var json = File.ReadAllText(path, Encoding.UTF8); var list = JsonConvert.DeserializeObject<List<RadioStation>>(json) ?? new(); // 入口就把脏数据挡掉:URL 不合法、codec 不认识的,直接跳过并记一行日志 return list.Where(s => Uri.TryCreate(s.Url, UriKind.Absolute, out _) && DecoderFactory.IsSupported(s.Codec)).ToList(); }

用户手填地址是脏数据的主要来源,常见的错法是漏了http://、把网页地址当成流地址、或者填了个 HLS 的 m3u8 但项目只支持 MP3。在Load里过滤一遍,比在播放时崩掉再回头查要省事得多。

4. 跑起来:环境、依赖还原和第一次出声

代码看懂了,接下来是让它出声。这一章按顺序走,每一步都有验证方法,跑不通就停在当前步骤排查,别往下跳。

4.1 环境准备与依赖还原

先确认 SDK。如果csproj里是<TargetFramework>net8.0-windows</TargetFramework>,装 .NET 8 SDK 就够;如果是老式的<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>,那得用 Visual Studio 打开,dotnet build认不了这种格式。

# 看装了哪些 SDK,确认有 8.x dotnet --list-sdks # 还原依赖(NAudio、Newtonsoft.Json 都从 NuGet 来) dotnet restore src/RadioNet.App/RadioNet.App.csproj # 编译,先 Debug,出问题好断点 dotnet build src/RadioNet.App/RadioNet.App.csproj -c Debug # 跑起来 dotnet run --project src/RadioNet.App/RadioNet.App.csproj

restore报 NU1101(找不到包)通常是网络问题,检查 NuGet 源配置;报 NU1701 是包只支持 .NET Framework 而被 .NET 8 项目引用,NAudio 老版本有这个问题,把版本提到 2.x 就能消掉警告。

4.2 三条命令之后应该看到什么

启动成功的话,主窗口是一个电台列表加底部一条播放控制栏。双击列表项开始播放,状态栏依次显示“连接中→缓冲中→播放中”,两三秒后出声,标题栏右侧出现当前节目名。

如果超过十秒还在“连接中”,八成是流地址本身不通。别急着怀疑代码,先拿命令行验一下这个地址到底返回什么:

# 只看响应头,-I 发 HEAD;如果是流地址没实现 HEAD,用 -r 0-0 只取一个字节 curl -s -D - -o /dev/null --max-time 8 "http://stream.local:8000/classic.mp3" # 重点看三行:HTTP 状态码、Content-Type、icy-metaint

返回 200 且Content-Type是audio/mpeg,说明地址没问题,问题在客户端;返回 404 或者 403,那是地址失效了,换一个台试。icy-metaint有值说明这个流带元数据,能显示节目名;没有值就只是纯音频,标题栏会一直是电台名,这是正常的,不是 bug。

4.3 第一次跑通要改的四个参数

源码里AppSettings暴露了几个可调项,配置文件在%AppData%\RadioNet\settings.json,改完重启生效:

配置键默认值作用什么时候调
HttpTimeoutSeconds15建立连接的等待时间网络差调到 30;调试时调到 5 快速失败
BufferDurationSeconds30PCM 缓冲区上限内存紧张调到 10;网络抖动强调到 60
DesiredLatencyMs200声卡输出延迟出现爆音调到 300~500
MaxRetryCount5断流后重连次数移动网络下调到 3,别无限重连

DesiredLatencyMs这个参数我建议先别动。默认 200ms 对电台足够,调太小(比如 50)在低配机器上会听到规律的“哒哒”声,那是缓冲区喂不上的欠载音。真要调,一次加 50,加完听三十秒再决定下一步。

5. 常见问题排查:没声音、断流、标题乱码这几件事

声音这条链路上能出问题的地方就那么几个,但症状相似、原因完全不同。这一章按“现象→原因→解决”的顺序列五条,都是这个项目最容易碰上的。

5.1 出声环节的四个坑

现象:点播放,状态一直停在“连接中”,界面上什么都不动。原因基本可以锁定在SendAsync用了默认的HttpCompletionOption。电台流是无限长的,默认模式会等整个响应体读完才返回,等于永远等不到。另一种可能是流地址被重定向到 HTTPS,而HttpClient没自动跟随,或者目标证书校验失败。 解决:确认SendAsync第三个参数传的是HttpCompletionOption.ResponseHeadersRead。如果是重定向问题,构造HttpClientHandler时把AllowAutoRedirect设为 true(默认就是 true,通常是被手动关掉了),并用curl -I看下 301 跳到了哪。

现象:能连上,BufferedBytes一直在涨,但一点声音都没有。原因:WaveOutEvent没有调用Play(),或者Init传的格式和实际解码出来的 PCM 格式对不上。前者是漏了一行,后者更隐蔽——有些流的 MP3 帧是 22050Hz 单声道,你按 44100Hz 立体声建缓冲,播放器会按错误速率解读,听起来像快进或者干脆静音。 解决:在AddSamples之前打一行日志,把frame.SampleRate、frame.ChannelMode打出来,和_buffer.WaveFormat对比。不匹配就在收到第一帧时重建 buffer 和输出设备。常见做法是第一次拿到帧后延迟初始化WaveOutEvent,而不是在构造函数里写死格式。

现象:播放十几秒后声音卡顿,日志里BufferedBytes反复在 0 附近跳。原因:拉流线程和解码跟不上播放速度,缓冲区长期处于干涸状态。常见于单线程同步Read加每帧Thread.Sleep的写法,或者解码器每次重新new一遍。 解决:解码器只在会话开始时创建一次,PumpAsync里循环复用;拉流改成async,别在 UI 线程上跑;把HighWaterBytes和BufferDuration一起提上去,让缓冲先攒够两秒音频再Play()。

5.2 跑起来之后才暴露的两个坑

现象:标题栏显示StreamTitle='...'原串,或者满屏乱码。原因有两个方向。乱码通常是编码假设错了,早期 ICY 元数据有按 ISO-8859-1 发的,也有按 UTF-8 发的,统一按 UTF-8 解就会出“锟斤拷”。显示原串则是正则没匹配上,直接回退成了原始文本。 解决:先按 UTF-8 解,解出来的字符串里出现替换字符\uFFFD就退回Encoding.GetEncoding("ISO-8859-1")再解一次。显示层只在StreamMetadata.Title非空时更新,否则保持上一次的值,不要把原始串直接怼到界面上。

现象:切台的时候抛“线程间操作无效:从不是创建控件的线程访问它”。原因:MetadataChanged和StateChanged都是在后台拉流线程上抛的,MainForm里直接赋值给了 Label 的 Text。 解决:事件处理器里统一判断InvokeRequired,用BeginInvoke而不是Invoke——Invoke是同步阻塞的,后台线程会等 UI 处理完才继续拉流,一个慢 UI 就拖垮整个播放。切台时先把旧的事件订阅读掉(player.MetadataChanged -= handler),再调StopAsync,最后挂新的订阅,顺序错了会收到上一个台的残留事件。

这两类问题的共同点是:播放本身没问题,只是显示错了或者崩在 UI 线程上。所以排查的时候先听声音,有声音就说明内核是好的,问题在展示层,范围一下子缩小一半。

6. 二次开发:加录音、加定时、换成自己的电台源

内核跑通之后,往上加功能的路子很清楚——因为所有音频都流经BufferedWaveProvider,想录就录,想分析就分析,不用重新解一遍。这里给三个改造方向,按实现难度排。

最实用的是录音。找到解码后写缓冲的那一行,在后面挂一个WaveFileWriter就行:

// RadioPlayer 里加两个成员 private WaveFileWriter _recorder; public void StartRecording(string filePath) { // 格式必须和 _buffer.WaveFormat 完全一致,否则录出来的文件会变调 _recorder = new WaveFileWriter(filePath, _buffer.WaveFormat); } private void WriteToBuffer(byte[] pcm, int count) { _buffer.AddSamples(pcm, 0, count); _recorder?.Write(pcm, 0, count); // 录音和播放共用同一份 PCM }

坑在于格式对齐:如果你在第一次拿到 MP3 帧之后重建了缓冲格式,那录音器也得在那一刻之后创建,不能提前在构造函数里new。切台时记得_recorder?.Dispose()并把引用置空,否则文件句柄一直占着,下次开不了同名文件。

第二个是定时关闭,实现成本更低。在MainForm里挂一个System.Threading.Timer,到点回调里await player.StopAsync(),注意回调是在线程池线程上触发的,改界面文字仍然要走BeginInvoke。想做得细一点,可以让定时器每 30 秒更新一次剩余时间,而不是一次性设一个长定时——用户中途取消的时候好处理。

第三个是换数据源。StationRepository.Load()现在只认本地 JSON,如果想让程序支持“从一份在线清单同步”,改法是在Load前面加一个TrySyncAsync,把远程内容拉下来覆盖 AppData 那份,失败就静默回退到本地。这里有两点血泪经验:一是同步下来的清单必须走一遍和本地一样的校验(URL 合法、codec 支持),别信数据源;二是一定要保留旧文件做备份,我见过一次同步到半截网络断了,把用户攒了几十个台的清单写成了空文件,没有后悔药。

改造点改动位置工作量风险
录音RadioPlayer 的 PCM 出口小格式不一致导致变调
定时关闭MainForm + Timer小跨线程改控件
在线清单同步StationRepository中覆盖用户数据
加 AAC/HLS 支持DecoderFactory大需要另接解码库

验证改造有没有做对,我的办法是加一条“录音回放”检查:录 30 秒,用系统自带播放器打开,听有没有变速、爆音、结尾截断。变速说明格式对不上,爆音说明中途切了台但录音器没重建,结尾截断是Dispose时没Flush。这三条过一遍,基本就稳了。

从那以后我每次动音频管线的代码,都会先录 30 秒再回放一遍,哪怕只是改了一行缓冲大小——耳朵比日志诚实得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询