首页 / 资讯中心 / 文章详情

C#网络收音机源码:NAudio音频流播放与电台解析

C#网络收音机源码:NAudio音频流播放与电台解析 ★ FEATURED ARTICLE
简介这是一份面向C#初学者与中级开发者的网络收音机源代码围绕互联网音频广播的接收与播放展开可用于学习Socket网络连接、音频流读取、事件驱动编程及桌面端界面设计。压缩包共34个文件包含6个cs源码、9个dll依赖库、3个exe程序以及resx、resources等界面与资源配置文件整体约334KB体量轻便便于直接打开解决方案梳理项目结构。已有702人学习下载说明其在C#多媒体小项目练习中具有一定参考价值。代码中可重点观察电台URL解析、TCP连接建立、音频数据流读取与播放控制以及将写死电台改为XML、JSON或SQLite存储后动态生成播放列表的思路同时可结合WinForms/WPF界面理解播放、暂停、停止、切换与音量调节等交互实现并在此基础上扩展搜索、收藏和播放历史等功能。1. 网络收音机源代码能干什么一份 C# 桌面播放器的完整落地样本前阵子有人找我说他手里攒了一堆电台流地址想做个 Windows 小工具点一下就能听顺手把常听的存下来最好还能录一段。这类需求一份网络收音机源代码就能当起点C# 写的桌面程序外壳是普通窗口内核负责把 HTTP 上的音频流拉下来、解码、推进声卡外围再挂上电台目录解析、搜索、收藏、录音几条支线。它适合两类人。一类是想搞明白「音频流从一串 URL 到扬声器中间到底发生了什么」的 C# 开发者播放器是最好的练手题材链路短、现象直观、出问题一眼能看见。另一类是手里有现成壳子想改成业务工具的人比如内部广播播报、值班室背景音、外语听力素材轮播功能不花哨但架子完整不用从零搭。2. 项目结构与技术选型WinForms 界面和 NAudio 播放内核怎么分工先把分层讲清楚后面看代码才不迷路。这套结构我一般拆成四块界面只管画和点播放内核只管把字节变成声音目录模块只管把各种列表格式翻译成统一对象存储模块只管把收藏和历史落盘。四块之间靠接口和事件通信界面订阅播放器状态播放器不回头引用任何窗体类型。这个约束听起来像教条实际是给自己留后路——哪天要把 WinForms 换成 WPF 或者加个托盘常驻模式内核一行不用动。播放内核和网络之间的边界要划在「流」这一层。播放器拿到的是一个可读的 Stream至于这个 Stream 是 HTTP 长连接、本地文件还是测试用的假数据它不关心。这样一来写单元测试时塞一个MemoryStream就能跑通解码逻辑不用真的去连外网。2.1 目录结构UI、播放内核、目录解析、本地存储四条线工程不多一条主程序加三层类库测试单独一个项目。我习惯把内核做成独立程序集强制它不能引用System.Windows.Forms编译不过就是提醒你分层破了。RadioDesk/ ├── RadioDesk.sln ├── src/ │ ├── RadioDesk.App/ # 界面层窗体、控件、ViewModel │ │ ├── MainForm.cs │ │ └── Controls/StationListControl.cs │ ├── RadioDesk.Core/ # 播放内核不引用任何 UI 程序集 │ │ ├── Playback/RadioPlayer.cs # 对外只暴露 Play/Pause/Stop 状态事件 │ │ ├── Playback/BufferedPump.cs # 读流 - 解码 - 填充缓冲区 │ │ ├── Playback/IcyStreamReader.cs # 剥掉 icy 元数据块的流包装 │ │ └── Playback/Recorder.cs # 把 PCM 落成 wav │ ├── RadioDesk.Catalog/ # 目录抓取、解析、搜索 │ │ ├── PlaylistParser.cs │ │ ├── CatalogClient.cs │ │ └── Models/Station.cs │ └── RadioDesk.Storage/ # 收藏、历史、配置 │ └── FavoriteStore.cs └── tests/ └── RadioDesk.Tests/ # 解析器和重连逻辑的单测RadioDesk.Core里最值得看的是RadioPlayer它对外只留三个方法和两个事件StateChanged、MetadataChanged界面拿到的信息就这么多。很多人写播放器喜欢让窗体直接持有WaveOutEvent图省事等到要加「一个窗口听台、另一个窗口录音」就抓瞎了因为输出设备只有一个实例两边抢。RadioDesk.Catalog单独拆出来是因为它变化最快。公共目录接口的返回格式说改就改把解析逻辑关在这一层上层不受影响。Models/Station.cs是全项目的中枢数据结构播放、收藏、搜索都围绕它转。2.2 播放内核为什么落到 NAudio三条备选路线对比C# 里能出声的方案就那么几种选型时我做过一张对照表结论是 NAudio 的可控性最合适。方案网络流支持元数据可控性典型问题系统自带播放控件弱基本拿不到低长连接超时不可调出错只给一个错误码NAudio好自己解析高需要自己写缓冲和重连带完整解码器的音频引擎好有中体积大AAC/FLAC 支持看授权系统自带控件的问题不在能不能放而在出了问题你没法查。它把网络请求、缓冲、解码、输出全包成黑匣子断了只告诉你「播放失败」日志里什么都没有。电台流又是一堆不规范的服务端超时时间、重定向、元数据格式各有各的脾气没有中间层就只能靠猜。NAudio 的路子是把链路拆开给你WaveOutEvent负责往声卡送 PCMBufferedWaveProvider负责缓冲MP3 帧的解压交给Mp3FrameDecompressor。拆开的代价是你得自己写拉流循环和重连好处是每一段都能打日志、都能替换。血泪经验是——播放器项目 80% 的故障排查时间都花在「到底是网络断了、解码挂了、还是缓冲空了」这个三选一上链路一拆开看一眼缓冲水位就知道了。需要说明的是NAudio 自带的解压器以 MP3 为主遇到 AAC 或者 HE-AAC 的台通常做法是加一个基于系统解码器的封装或者干脆在目录里过滤掉不支持的编码格式别硬扛扛不动。2.3 第一次跑起来环境、依赖和启动顺序目标框架写的是 .NET 6 桌面运行时老一点的版本是 .NET Framework 4.8两者的差异主要在HttpClient的默认行为和System.Text.Json的可用性上核心代码改动不大。依赖只有 NAudio 一个包其余全是 BCL。# 1. 确认 SDK 版本桌面开发要带 Windows 目标 dotnet --list-sdks # 2. 还原依赖NAudio 及其依赖会一起拉下来 dotnet restore RadioDesk.sln # 3. 直接跑第一次会编译三个类库 dotnet run --project src/RadioDesk.App/RadioDesk.App.csproj # 4. 只跑解析和重连的单测不需要声卡 dotnet test tests/RadioDesk.Tests/RadioDesk.Tests.csproj启动顺序上有个容易忽略的点播放器实例要在窗体构造函数里创建但WaveOutEvent的Init要等到第一次点播放才做。原因很简单程序一启动就占住声卡别的软件正在放歌时会互相打架用户第一反应是「这破软件把我音乐停了」。把Init推迟到真正要出声的那一刻退出时在FormClosing里调Dispose这套节奏基本不会出问题。配置文件不放在 exe 旁边。默认写%APPDATA%\RadioDesk\一是 Program Files 目录默认没有写权限二是重新编译发布时不会把用户收藏覆盖掉。这个坑我踩过一次清理 bin 目录顺手把收藏夹一起清了用户当场沉默。3. 音频播放内核一个电台 URL 从进来到出声的完整链路整条链路分四段发请求拿响应头、按需读取音频字节、剥掉夹在中间的元数据、解码成 PCM 塞进缓冲。四段里任何一段处理不当表现都是「没声音」但原因天差地别所以每一段都要能单独看到状态。先看第一段。网络收音机和服务端的交互和普通 HTTP 请求有一个关键区别响应体永远不结束。你要是按默认方式等它读完程序会一直卡在那儿直到超时。3.1 拉流带上 Icy-MetaData 头去要数据只读响应头不等响应体public sealed class RadioStream : IDisposable { private readonly HttpClient _http; public int MetaInterval { get; private set; } // icy-metaint0 表示服务端不给曲目信息 public RadioStream() { // 长连接不能被默认的 100 秒超时掐断这里直接关掉超时 _http new HttpClient { Timeout Timeout.InfiniteTimeSpan }; _http.DefaultRequestHeaders.UserAgent.ParseAdd(RadioDesk/1.0); _http.DefaultRequestHeaders.Add(Icy-MetaData, 1); // 告诉服务端把曲目名一起发 } public async TaskStream OpenAsync(string url, CancellationToken ct) { // 关键参数ResponseHeadersRead拿到响应头就返回不缓冲整个响应体 var resp await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); resp.EnsureSuccessStatusCode(); if (int.TryParse(GetHeader(resp, icy-metaint), out var n) n 0) MetaInterval n; // 服务端承诺每 n 字节音频后面跟一段元数据 ContentType resp.Content.Headers.ContentType?.MediaType ?? audio/mpeg; return await resp.Content.ReadAsStreamAsync(ct); } private static string? GetHeader(HttpResponseMessage r, string name) r.Headers.TryGetValues(name, out var v) ? v.FirstOrDefault() : null; }HttpCompletionOption.ResponseHeadersRead是这段代码里最重要的一行。默认值ResponseContentRead会等到整个响应体读完才返回对无限长的音频流来说等于永久阻塞表现出来就是点了播放没反应一两分钟后弹个超时。改成只读头ReadAsStreamAsync立刻返回一个可以边读边播的流。Timeout Timeout.InfiniteTimeSpan也是必须的。HttpClient默认 100 秒超时而电台流正常播放时会持续几十秒没有任何「响应结束」的信号某些实现在这个点上会误判超时。关掉之后断流的检测交给读取端读不到数据就抛异常比超时机制准确得多。Icy-MetaData: 1这个请求头决定服务端会不会在音频流里插曲目信息以及插多密。不加这个头播放一切正常但界面上永远显示不出正在放什么歌。返回头里的icy-metaint是间隔字节数后面剥元数据全靠它。3.2 缓冲与重连BufferedWaveProvider 的水位和断线恢复缓冲区的作用是吸收网络抖动。没有它网络抖 200 毫秒声音就断 200 毫秒听感上是连续的「咔咔」声。// 初始化8 秒缓冲溢出丢弃旧数据 _buffered new BufferedWaveProvider(new WaveFormat(44100, 16, 2)) { BufferDuration TimeSpan.FromSeconds(8), DiscardOnBufferOverflow true // 网络突然变快时宁可丢旧帧也别让内存涨上去 }; _output new WaveOutEvent { DesiredLatency 300, // 单次输出延迟越小越跟手但更容易被缓冲不足拖垮 NumberOfBuffers 3 // 三缓冲给调度留点余量 }; _output.Init(_buffered); _output.Play();BufferDuration是缓冲能装多久的音频不是延迟。很多人把它当成延迟来调设成 1 秒想「更实时」结果网络一抖就断。8 秒是这类电台流比较合适的值正常网络下永远填不满弱网时能扛住几秒的空窗。DiscardOnBufferOverflow打开之后缓冲满了会丢掉最早的数据代价是音频内容跳跃但至少程序不会因为内存持续增长被系统干掉。解码循环本身不复杂难点在边界处理private void PumpLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { // 1. 从流里取一个完整 MP3 帧取不到说明流结束或出错 var frame Mp3Frame.LoadFromStream(_stream); if (frame null) break; // 2. 解压成 PCM写入临时缓冲 int read _decompressor.DecompressFrame(frame, _pcmBuffer, 0); // 3. 缓冲快满时原地等别把整个电台缓存进内存 while (_buffered.BufferedBytes _buffered.BufferLength - read) { if (ct.IsCancellationRequested) return; Thread.Sleep(20); } _buffered.AddSamples(_pcmBuffer, 0, read); } }第三步的等待是必要的保护。解码速度通常远快于播放速度不加限流几秒钟就能把缓冲区冲到上限然后一直丢帧反而制造卡顿。等 20 毫秒是个经验值配合 8 秒缓冲水位基本在三分之一到三分之二之间晃。调试时把这个水位打到日志里卡顿问题一眼就能定位是网络供给不足还是消费端太慢。重连放在外层逻辑要简单粗暴private async Task PlayWithRetryAsync(string url, CancellationToken ct) { int retry 0; while (!ct.IsCancellationRequested) { try { await OpenAndPumpAsync(url, ct); retry 0; // 正常收尾计数清零 } catch (Exception ex) when (retry 5) { retry; int delay Math.Min(1000 * retry, 8000); // 1s、2s、3s… 封顶 8s OnStateChanged($第 {retry} 次重连{delay} 毫秒后重试{ex.Message}); await Task.Delay(delay, ct); } } }退避不设上限会出问题一个已经下线的台重试间隔涨到几分钟用户点了没反应以为程序卡死了。封顶 8 秒配合状态栏文案用户能看出「它在努力但那个台没了」。重连成功之后一定要把_decompressor和旧流都释放掉再新建复用解码器的后果在避坑那章细说。3.3 元数据、音量与录音把 Playing 状态用起来曲目信息被夹在音频字节里必须在解码之前剥掉否则会被当成 MP3 帧数据喂给解压器结果是刺耳的噪音或者直接解码失败。做法是包一层 Stream控制每次读取不超过元数据边界public override int Read(byte[] buffer, int offset, int count) { if (_metaInterval 0) return _inner.Read(buffer, offset, count); // 不允许跨过元数据边界否则曲目名会被当成音频 count Math.Min(count, _metaInterval - _readInBlock); int read _inner.Read(buffer, offset, count); _readInBlock read; if (_readInBlock _metaInterval) { int lenByte _inner.ReadByte(); // 元数据块长度 该字节 * 16 int metaLen lenByte * 16; if (metaLen 0) { var meta ReadExactly(metaLen); // 形如 StreamTitle某某 - 某某; _lastTitle ExtractStreamTitle(meta); MetadataChanged?.Invoke(this, _lastTitle); } _readInBlock 0; // 重置进入下一个音频块 } return read; }count Math.Min(count, _metaInterval - _readInBlock)这一行是全篇最容易写错的地方。少这一句上层一次读 16KB直接把元数据块吞进去一半解码器当场报错。加了这个限制读取永远在边界上停住元数据被干净地切走。MetadataChanged事件要小心线程问题。它从解码线程抛出来界面层必须Invoke回 UI 线程再改控件否则偶发的跨线程异常会让程序毫无征兆地崩掉而且复现极难。音量不要用WaveOutEvent.Volume那个属性依赖声卡驱动支持很多设备上是个空实现调了没反应。正确做法是在 PCM 上乘系数// 音量作用在 PCM 样本上跨设备行为一致 private void ApplyVolume(byte[] buffer, int bytes, float gain) { if (Math.Abs(gain - 1f) 0.001f) return; for (int i 0; i 1 bytes; i 2) // 16bit每两个字节一个样本 { short s (short)(buffer[i] | (buffer[i 1] 8)); int v (int)(s * gain); v Math.Clamp(v, short.MinValue, short.MaxValue); buffer[i] (byte)(v 0xFF); buffer[i 1] (byte)((v 8) 0xFF); } }录音就是在AddSamples之前多走一步把同一段 PCM 同时写进WaveFileWriter。注意WaveFileWriter的格式必须和BufferedWaveProvider完全一致采样率、位深、声道数三样错一样录出来的文件就是快放或者慢放。录完记得Dispose否则 wav 头里的长度字段是 0播放器识别成一个空文件。4. 电台目录与解析PLS、M3U、JSON 三种列表格式怎么统一吃掉电台列表的格式混乱程度超出很多人的预期。同一个目录服务网页端给 JSON桌面客户端给 PLS移动端给 M3U还有一堆自建台直接返回一行一个裸 URL 的纯文本。播放器不可能为每种格式写一套界面所以必须有一个统一入口把外部格式全部翻译成Station列表。这一层做得好不好直接决定项目能不能扩展。解析器写成 if-else 堆在窗体里加一种格式就要动界面抽成独立类库加格式只是加一个方法。4.1 数据模型一个 Station 类要装下哪些字段字段设计上宁少勿多但几个关键字段一个都不能省。public class Station { public string Id { get; set; } Guid.NewGuid().ToString(N); public string Name { get; set; } ; // 显示名解析不到就退回域名 public string Url { get; set; } ; // 流地址唯一必填项 public string? Homepage { get; set; } // 官网做「详情」时用 public string? Genre { get; set; } // 分类搜索和筛选都靠它 public int Bitrate { get; set; } // 码率排序用未知填 0 public string? Codec { get; set; } // mp3 / aac播放前先过滤掉不支持的 public bool IsFavorite { get; set; } }Url是唯一的硬性字段其他都可以缺。这看起来是废话但实际数据里半数以上的条目缺少Name解析时不用Uri.Host兜底列表上就是一排空白行。Bitrate填 0 而不是留 null是避免排序时出现 null 参与比较的异常这个坑在线上出现过一次一个畸形条目让整个列表加载失败。Id用随机串而不是Url的哈希是因为同一路流可能有多个地址备用域名换地址后收藏不该丢。判断重复用Url归一化后比较展示用Id。4.2 解析入口按扩展名和内容特征分流分流逻辑不能只看扩展名。目录服务经常把 PLS 内容挂在.aspx结尾的地址上Content-Type 还标成text/html。所以先用提示信息试一遍再用内容特征兜底。public static IReadOnlyListStation Parse(string content, string hint) { var head content.TrimStart(); // 1. 先按外部提示走命中率最高 if (hint.Contains(json, StringComparison.OrdinalIgnoreCase) || head.StartsWith({)) return ParseJson(content); // 2. 再按内容首行特征兜底 if (content.Contains([playlist], StringComparison.OrdinalIgnoreCase)) return ParsePls(content); if (content.Contains(#EXTM3U, StringComparison.OrdinalIgnoreCase)) return ParseM3u(content); // 3. 最后当纯文本一行一个裸 URL自建台最常见的形式 return content.Split(\n) .Select(l l.Trim()) .Where(l l.StartsWith(http, StringComparison.OrdinalIgnoreCase)) .Select(l new Station { Name SafeHost(l), Url l }) .ToList(); }三段式的顺序有讲究。JSON 的判断放第一位因为{开头的判断几乎不会误伤。PLS 和 M3U 的标志行用Contains而不是StartsWith很多服务端会在前面塞几行注释或者 BOM 头。SafeHost是个小工具方法new Uri(url)遇到畸形地址会抛异常整个列表就废了。包一层 try-catch 返回空串坏条目被跳过好条目照常显示容错比严格更重要。PLS 解析的细节在配对private static ListStation ParsePls(string text) { var files new Dictionaryint, string(); var titles new Dictionaryint, string(); foreach (var raw in text.Split(\n)) { var s raw.Trim(); if (!s.StartsWith(File) !s.StartsWith(Title)) continue; int eq s.IndexOf(); if (eq 0) continue; var key s[..eq]; // File1 / Title1 if (!int.TryParse(key[4..], out var num)) continue; // 序号File 和 Title 靠它配对 var value s[(eq 1)..].Trim(); if (key.StartsWith(File)) files[num] value; else titles[num] value; } return files.OrderBy(kv kv.Key) .Select(kv new Station { Url kv.Value, Name titles.GetValueOrDefault(kv.Key) ?? SafeHost(kv.Value) }) .ToList(); }File和Title靠序号配对直接顺序读取会错位——很多 PLS 文件里 Title 的顺序和 File 不一致甚至有的 Title 缺失。4.3 收藏与播放历史本地 JSON 持久化的写法收藏数据量小用 JSON 文件足够没必要上数据库。但写文件的方式要讲究public void Save() { var dir Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), RadioDesk); Directory.CreateDirectory(dir); // 首次运行目录不存在先建 var path Path.Combine(dir, favorites.json); var tmp path .tmp; // 先写临时文件再原子替换进程中途崩溃不会留下半截 JSON File.WriteAllText(tmp, JsonSerializer.Serialize(_favorites, JsonOptions)); File.Move(tmp, path, overwrite: true); }先写.tmp再File.Move是防止写到一半断电留下一个解析不了的坏文件。坏文件的后果是下次启动加载失败收藏全部丢失用户会觉得这软件不可靠。加载端同样要加保护反序列化异常时把旧文件改名备份然后返回空列表至少程序能启动。播放历史单独存一个文件只保留最近 200 条写入时机放在切台和退出时不要每次状态变化都写盘——File.WriteAllText是同步 IO频繁调用会卡界面。5. 避坑与排查弱网、无声、界面卡死这五类问题逐个拆播放器这类程序代码写对只是及格线真正花时间的是「为什么这个台放不出来」。下面五条是我在实际调试中反复遇到的按现象、原因、解决三段写遇到类似症状可以直接对号入座。5.1 播放侧没声音、爆音、播到一半停住现象进度显示在跑缓冲区水位也在涨但一点声音都没有。原因通常是WaveOutEvent.Init了错误的格式或者干脆没调用Play。BufferedWaveProvider的WaveFormat必须和数据源完全一致44100/16/2 的数据塞进一个声明为 48000 的缓冲播出来是快放加静音。另一种可能是这个台实际是 HE-AAC 编码而解压器只认 MP3 帧读到的「帧」全是无效数据缓冲区自然填不满。 解决在StateChanged里把_output.PlaybackState和_buffered.BufferedBytes一起打日志两者都有值却听不见就检查WaveFormat水位长期为零检查流的Content-Type把非 mp3 的台在列表里标灰。现象换台之后出现持续的电流声或刺耳爆音。原因是解码器实例被复用上一个台的最后一帧残留在内部状态里和新台的帧拼在一起。或者上一次的_stream没关闭两路数据同时往一个缓冲里写。 解决切台走统一的Stop()按顺序_output.Stop()、_decompressor.Dispose()、_stream.Dispose()然后把_buffered.ClearBuffer()清空再重新开流。顺序错了照样出问题先清缓冲再停输出会有一小段残留播出来。现象听了三五分钟自动停住点播放也没反应必须重启程序。原因是解码线程在缓冲满的while循环里死等而输出端因为某种原因不再消费比如设备被其他程序独占双方互锁。也可能是Mp3Frame.LoadFromStream在流半关闭状态下永久阻塞。 解决等待循环里加上退出条件不要写成死循环读取端给流套一层带超时的包装超时就抛异常走重连。这个改动看起来会让代码变复杂但比让用户重启程序强。5.2 网络与界面侧请求超时、UI 卡死、配置丢失现象点击某个台界面卡死十几秒鼠标变成沙漏。原因是用了.Result或者.Wait()在 UI 线程上同步等 HTTP 响应。WinForms 的消息泵被阻塞窗口不重绘用户以为程序挂了。 解决从按钮事件一路async到HttpClient.GetAsync中间的等待全部await。事件回调里更新控件时用Invoke不要图省事直接改Text。改完之后还要检查一个地方——事件处理函数如果是async void异常会直接吞掉必须在最外层 try-catch 并弹状态栏提示。现象收藏列表重启后变空或者文件内容变成半截 JSON。原因有两个。一是写文件时没有做原子替换写到一半进程被杀任务管理器强杀、系统更新重启文件损坏。二是路径写在了 exe 同目录用户把程序装在Program Files下没有写权限WriteAllText抛异常被一个空的 catch 吞掉。 解决路径改到%APPDATA%写入走临时文件替换catch 里至少记一行日志。判断路径是否可写有个简单办法启动时写个探针文件失败就提示用户换目录。提示排查网络类问题时先把日志级别调到 Debug把响应头里的icy-metaint、Content-Type、状态码全部打出来。这些信息不打印后面所有猜测都是空的。6. 二次开发改造把播放器改成自己的电台工具拿到这套代码多数人不会原样用而是想改成一个具体场景的工具。改之前先想清楚改哪一层能省掉大量返工。最常见的四类改造代价差别很大改造方向主要改动文件工作量注意点预约录音 / 定时录制Recorder.cs、RadioPlayer.cs小定时器要用系统时间校准别用累加加频谱可视化BufferedPump.cs旁挂一个 FFT中在 PCM 上做不要在解码后立即做托盘常驻 / 开机自启MainForm.cs、启动项注册小关窗要拦截FormClosing改最小化支持 AAC 编码的台Playback/下加一个解压器实现大先确认目录里非 mp3 台占比别为一个台改架构先说录音增强。原版的录音是手动开关改成预约录制只需要在Recorder外面套一层调度给每个收藏台配一个时间段到点自动切台、开录、录满时长停止。有个细节必须处理——录音文件按「台名-日期-开始时间」命名时间取录制真正开始的那一刻而不是计划时刻否则网络重连几次之后文件名和内容对不上事后找录音会疯。频谱可视化是很多人会加的东西坑在位置。把 FFT 挂在解码之后、写入缓冲之前拿到的是「已经解码但还没播」的数据画出来的频谱比声音早 8 秒缓冲的长度看着就是音画不同步。正确位置是在BufferedWaveProvider的读取侧或者干脆用WaveOutEvent的数据回调两者都能拿到真正送进声卡的那一段。改完怎么验证别指望每次都有真实电台可用本地起一个假服务更靠谱// 测试用本地 HTTP 服务循环发送一段 10 秒的 MP3到 3 秒时主动断开 var listener new HttpListener(); listener.Prefixes.Add(http://localhost:18080/); listener.Start(); _ Task.Run(async () { var ctx await listener.GetContextAsync(); ctx.Response.ContentType audio/mpeg; ctx.Response.SendChunked true; var pcm File.ReadAllBytes(sample.mp3); await ctx.Response.OutputStream.WriteAsync(pcm.AsMemory(0, pcm.Length / 2)); await Task.Delay(3000); ctx.Response.Abort(); // 模拟服务端断流验证重连逻辑 });这个假服务能覆盖重连、缓冲水位、元数据缺失三种情况比连真实电台稳定得多——真实电台有时好有时坏你根本分不清是代码问题还是对方问题。测试时把重连间隔调到 200 毫秒肉眼就能看出重连流程对不对。还有个小技巧值得单说把RadioPlayer的状态做成一个枚举加事件界面只订阅不改状态。这样同一个内核可以同时驱动主窗口、托盘图标和命令行模式三处显示的状态天然一致。我见过太多项目在托盘图标上单独维护一份「当前是否在播」的布尔值结果和主窗口对不上用户点一下托盘暂停主窗口还显示在播。从那以后我每次加第二种 UI 入口都先确认状态只有一个来源别的地方只读不写。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站