资讯详情 C#网络收音机源码解析:HTTP音频流、解码后端与播放链路
📅 2026/11/5 4:59:15
简介网络收音机源代码是一份基于C#编写的网络音频广播接收与播放示例面向希望学习网络编程、音频流处理、事件驱动和桌面界面设计的初、中级开发者可用于理解从电台地址解析、TCP连接到音频数据读取与播放的完整实现链路。压缩包共34个文件以6个cs源码、9个dll依赖、3个exe程序及resx、resources配置资源为主并含sln解决方案、csproj项目文件和ico图标等整体约334KB便于在Visual Studio中打开分析。已有702人学习下载。代码中可重点查看Socket与Stream读取音频流、MediaPlayer或WaveOut播放、播放状态事件响应以及WinForms或WPF界面中的播放暂停、停止、切台和音量控制也能借鉴将硬编码电台地址改为XML、JSON或SQLite存储并动态生成播放列表的思路并在此基础上扩展搜索、分类、收藏和播放历史等功能对改造旧项目或补充多媒体播放能力也有参考意义。1. 网络收音机 C# 源代码先拆哪三块流地址、解码后端和界面线程手头这份网络收音机 C# 源代码最容易让人误判的地方是界面按钮齐全、列表好看点下去却可能只有沙沙声。它真正要解决的是把 HTTP 音频流接进桌面端播放链路——解析播放列表、选择解码后端、管理缓冲与重连、把 PCM 数据持续送进声卡再把 ICY 标题和录音、定时这些外围模块挂上去。适合正在找 C# 桌面流媒体练手项目的人也适合要把电台收听、录制、定时播放塞进自有工具链的开发者。读这类源码先看流地址怎么分类、播放状态机怎么收口、UI 线程和音频线程怎么隔离界面层反而可以放到最后看。2. 播放链路从 HTTP 到声卡C# 里四个可复现步骤网络收音机源码的播放链路表面看只有“点播放”一个动作拆开以后至少有四段拿到播放地址、判断地址类型、把原始字节解成 PCM、把 PCM 按固定节奏写给声卡。很多项目编译能过、运行就哑问题往往不在最后一段声卡而在第一段就把播放列表当成了音频文件或者把 MP3 原始字节直接塞给了 PCM 输出。下面按可复现顺序拆每一步都给一个能落地的代码骨架参数按常见桌面端场景给。2.1 流地址不是文件地址直链、播放列表和分片流先分开网络收音机源码里常见的地址有三类。第一类是直链流URL 以.mp3、.aac、.ogg结尾或者响应头Content-Type是audio/mpegHttpClient拿到的是一条长连接读到的是连续音频字节。第二类是播放列表URL 以.m3u、.pls结尾返回的是文本里面可能列了多个候选地址也可能嵌套一层列表。第三类是分片流.m3u8只是索引文件真正播放要按索引去拉.ts或.m4s分片再按顺序拼接。把第三类当第二类解析是这类源码里很典型的翻车点列表解析出来了播放器却把#EXTINF当成音频地址。我一般不会只靠扩展名判断而是先发一个带Range的 GET只读前 4KB看Content-Type和文本开头。这样比 HEAD 更可靠因为有些流媒体端点对 HEAD 支持不完整。代码骨架如下public enum StreamKind { Direct, Playlist, Segmented, Unknown } public static async TaskStreamKind DetectAsync(Uri uri, CancellationToken ct) { using var http new HttpClient { Timeout TimeSpan.FromSeconds(10) }; using var req new HttpRequestMessage(HttpMethod.Get, uri); req.Headers.Range new RangeHeaderValue(0, 4095); // 只探测前 4KB不拉全量 using var resp await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); var mediaType resp.Content.Headers.ContentType?.MediaType ?? ; if (mediaType.Contains(mpegurl)) return StreamKind.Segmented; if (mediaType.StartsWith(audio/)) return StreamKind.Direct; var text await resp.Content.ReadAsStringAsync(ct); if (text.StartsWith(#EXTM3U)) return StreamKind.Playlist; if (text.TrimStart().StartsWith([playlist])) return StreamKind.Playlist; return StreamKind.Unknown; }逻辑说明先看响应类型application/vnd.apple.mpegurl这类直接归到分片流audio/*归到直链剩下读一小段文本用#EXTM3U和[playlist]判断普通播放列表。代码里没有把.m3u8写死成普通列表是因为它和.m3u只差一个字符处理路径完全不同。参数说明Range取 0 到 4095探测成本低Timeout给 10 秒太短会在移动网络下误判太长会让界面卡在“连接中”ReadAsStringAsync只对未知类型执行二进制流不会走到这里。实际项目里还可以把探测结果缓存到电台配置里下次直接按类型走减少一次请求。2.2 选播放后端三条路线的边界和源码特征C# 桌面端做网络收音机播放后端常见三条路线。路线 A 是自己管解封装、解码和 PCM 输出源码里通常能看到IDecoder、IAudioSink这类接口优点是可控缺点是格式覆盖要靠自己补。路线 B 是用多格式解码封装库把解封装、解码、输出一起接管源码里往往只有一个Play(url)方法二次开发要找它暴露的事件和回调。路线 C 是用低层音频库直接处理 PCM适合已经确定输入格式、只想把数据送声卡的场景。三条路线没有绝对好坏关键看源码原本的切口在哪里。路线源码特征适合场景主要限制落地检查A 自管解码与输出有IDecoder、IAudioSink接口想换解码器、想接录音和音量归一格式覆盖要自己补看解码器是否输出固定 PCM 格式B 多格式封装库单个Play方法事件回调多快速支持 MP3/AAC/OGG缓冲和重连参数不透明看异常事件和状态事件是否齐全C 低层 PCM 输出只负责写 PCM输入格式固定已有解码模块只缺声卡输出不解决解封装看采样率、声道、位深是否匹配选型时我会先看源码有没有统一输出格式。常见做法是把解码结果统一成 44100Hz、16bit、双声道 PCM再往声卡送这样录音、音量归一、静音检测都只需要处理一种数据形态。如果源码里每个电台各自带一套采样率后面加录音就会遇到“录出来的 WAV 头对不上”的问题。路线 B 省事但要在初始化时把缓冲时长、重连次数、超时这些参数暴露到配置里否则用户只能改代码。路线 A 适合二次开发但要把解码失败和网络失败分开报别让用户只看到一个“播放失败”。2.3 最小可跑的播放循环缓冲、重连和状态机播放循环不要写成“打开流、一直读、读到结束”。网络收音机是长连接断流、超时、服务端主动关闭都会发生循环里要有状态和重试。下面这个骨架把状态机、单次连接和重试分开方便在源码里找到对应位置替换。public enum PlayerState { Idle, Connecting, Buffering, Playing, Reconnecting, Stopped } public sealed class PlaybackController { private readonly IAudioSink _sink; private readonly IStreamDecoder _decoder; private readonly TimeSpan _bufferTime TimeSpan.FromMilliseconds(800); private readonly int _maxRetry 4; public async Task PlayAsync(Uri uri, CancellationToken ct) { var retry 0; while (!ct.IsCancellationRequested) { try { using var linked CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(TimeSpan.FromSeconds(20)); // 单次连接最多等 20 秒 await RunOnceAsync(uri, linked.Token); retry 0; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { retry; // 超时按失败处理继续退避重试 } catch (IOException) { retry; } if (retry _maxRetry) break; await Task.Delay(TimeSpan.FromSeconds(Math.Min(10, retry * 2)), ct); } } private async Task RunOnceAsync(Uri uri, CancellationToken ct) { using var http new HttpClient { Timeout Timeout.InfiniteTimeSpan }; using var resp await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct); resp.EnsureSuccessStatusCode(); await using var stream await resp.Content.ReadAsStreamAsync(ct); _decoder.Reset(stream); _sink.Start(_decoder.SampleRate, _decoder.Channels); var pcm new byte[16 * 1024]; while (!ct.IsCancellationRequested) { var n await _decoder.ReadAsync(pcm, ct); if (n 0) break; _sink.Write(pcm, 0, n); } } }逻辑说明外层PlayAsync负责重试内层RunOnceAsync只负责一次连接单次连接用CancelAfter卡住 20 秒避免某个端点不返回数据时界面一直停在“连接中”。_decoder.Reset(stream)表示解码器从这条 HTTP 流里读原始字节_sink.Start拿到解码器输出的采样率和声道数后面的循环只做“解码一段、写一段”。参数说明_bufferTime这里是 800ms直播流可以调到 500 到 1200ms太小容易在轻微抖动时断音太大换台会慢_maxRetry给 4 次退避按 2、4、6、8 秒递增最大不超过 10 秒pcm缓冲 16KB是 16bit 双声道下比较常见的块大小太小会增加写声卡次数太大会让停止响应变慢。真正落地时RunOnceAsync在_sink.Start之前还要预填一段静音或预读数据避免第一声出来前有爆破音。3. 电台列表与 ICY 元数据M3U、PLS、JSON 三种格式怎么落地播放链路跑通以后接下来就是“台从哪来、现在播的是什么”。网络收音机源码里电台列表和元数据是两个不同的数据源列表是用户维护的静态配置元数据是流里带出来的动态标题。两者混在一起写代码会越来越难改。我一般把列表解析放在配置层把 ICY 读取放在播放层中间用事件把标题传给界面。3.1 三种列表格式的字段差异和解析策略M3U 是最简单的文本列表一行一个地址#开头是注释或扩展信息PLS 是键值对File1...、Title1...按编号配对JSON 是项目自己定义的配置字段可控适合做分组、排序、最近播放。三种格式不要写成一个巨大的switch而是各自解析成统一的Station对象后面播放器只认Station。public sealed class Station { public string Id { get; set; } Guid.NewGuid().ToString(N); public string Name { get; set; } ; public string Url { get; set; } ; public string? Group { get; set; } } public static IEnumerableStation ParseM3U(string text) { foreach (var raw in text.Split(\n)) { var line raw.Trim(); if (line.Length 0 || line.StartsWith(#)) continue; if (!Uri.TryCreate(line, UriKind.Absolute, out var uri)) continue; yield return new Station { Name uri.Host, Url uri.ToString() }; } } public static IEnumerableStation ParsePls(string text) { var files new SortedDictionaryint, string(); var titles new SortedDictionaryint, string(); foreach (var raw in text.Split(\n)) { var line raw.Trim(); var idx line.IndexOf(); if (idx 0) continue; var key line[..idx].Trim(); var value line[(idx 1)..].Trim(); if (key.StartsWith(File, StringComparison.OrdinalIgnoreCase) int.TryParse(key[4..], out var n)) files[n] value; if (key.StartsWith(Title, StringComparison.OrdinalIgnoreCase) int.TryParse(key[5..], out var t)) titles[t] value; } foreach (var kv in files) yield return new Station { Name titles.TryGetValue(kv.Key, out var name) ? name : kv.Value, Url kv.Value }; }逻辑说明M3U 只取非空、非注释且能转成绝对 URI 的行名称先用主机名兜底PLS 先把FileN和TitleN分别收进有序字典再按编号合并避免文件顺序和标题顺序错位。两个方法都返回IEnumerableStation调用方可以用ToList()落到配置里。参数说明UriKind.Absolute会过滤掉相对路径网络电台列表里相对路径很少见过滤掉比后面播放时再报错更省事PLS 的编号从 1 开始代码里没有假设连续缺失编号不会影响其他项Name为空时不要直接写“未知电台”用 URL 主机名或文件名兜底用户至少能认出来。JSON 列表则建议单独用System.Text.Json反序列化字段里保留BufferMs和Volume这类播放参数不要把播放参数塞进每个Station。3.2 ICY metadata从响应头到滚动标题ICY 元数据是很多网络电台用来传“当前曲目”的机制。客户端请求时带Icy-MetaData: 1响应头里返回icy-metaint表示每多少字节音频数据后面跟一个元数据块元数据块第一个字节是长度乘以 16 就是实际元数据字节数里面通常是StreamTitle...;。这段逻辑不复杂但容易在“跨过音频块”时读错位置。public static async Taskstring? ReadIcyTitleAsync(Uri uri, CancellationToken ct) { using var http new HttpClient { Timeout TimeSpan.FromSeconds(10) }; using var req new HttpRequestMessage(HttpMethod.Get, uri); req.Headers.TryAddWithoutValidation(Icy-MetaData, 1); using var resp await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); if (!resp.Headers.TryGetValues(icy-metaint, out var vals)) return null; if (!int.TryParse(vals.FirstOrDefault(), out var metaInt) || metaInt 0) return null; await using var stream await resp.Content.ReadAsStreamAsync(ct); var audioSkip new byte[metaInt]; await stream.ReadExactlyAsync(audioSkip, ct); // 先跨过第一段音频 var lenByte stream.ReadByte(); if (lenByte 0) return null; var meta new byte[lenByte * 16]; await stream.ReadExactlyAsync(meta, ct); var text Encoding.UTF8.GetString(meta).Trim(\0, ); var match Regex.Match(text, StreamTitle(.*?);); return match.Success ? match.Groups[1].Value : null; }逻辑说明请求头里加Icy-MetaData: 1服务端才愿意返回icy-metaint拿到间隔后先读掉一段音频再读长度字节和元数据块。代码只读一次标题实际播放器要在播放循环里按metaInt周期读取读到空块就跳过。参数说明metaInt常见是 8192 或 16384代码没有写死lenByte * 16是协议规定的长度换算Regex只匹配StreamTitle不要把整段元数据直接显示到界面里面可能还有站名和广告字段。注意有些流不返回icy-metaint这时不要反复重试直接把标题功能置灰播放不受影响。3.3 配置持久化把电台、音量和最近播放写进 JSON网络收音机源码如果每次启动都从零开始用户体验会很差。配置层要存三样东西电台列表、播放参数、最近播放。存 JSON 比存二进制好排查出问题时打开文件就能看到。写入时用“临时文件 替换”避免程序崩溃把原配置写坏。public sealed class RadioConfig { public ListStation Stations { get; set; } new(); public int Volume { get; set; } 70; public int BufferMs { get; set; } 800; public string? LastStationId { get; set; } } public static void SaveConfig(string path, RadioConfig config) { var json JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented true }); var tmp path .tmp; File.WriteAllText(tmp, json, Encoding.UTF8); File.Move(tmp, path, overwrite: true); // 先写临时文件再替换 }逻辑说明RadioConfig把列表和播放参数放在同一层播放器启动时先读配置再决定用哪个电台和缓冲时长File.Move覆盖原文件比直接WriteAllText多一步临时文件断电时最多丢一次修改不会留下半个 JSON。参数说明Volume用 0 到 100 的整数不要存浮点界面滑块好绑BufferMs存毫秒默认 800用户可以在设置里改LastStationId存Station.Id不要存索引列表排序后索引会变。读取时给JsonSerializerOptions加PropertyNameCaseInsensitive true以后改字段大小写不至于读不出来。4. 录制、定时与音量归一三个模块的参数边界播放和列表跑通以后源码里真正拉开差距的是外围模块。录制决定能不能把听到的内容留下来定时决定能不能当闹钟用音量归一决定换台时会不会一个台震耳、一个台听不见。这三个模块都不难但参数边界没定好后面排错会很痛苦。4.1 边听边录PCM 落盘还是直接转码边听边录最直接的做法是把解码后的 PCM 写进 WAV 文件。好处是不依赖额外编码器坏处是文件大44100Hz、16bit、双声道一分钟大约 10MB。源码里如果已经有IAudioSink录音模块可以再实现一个IAudioSink把同样的 PCM 同时写给声卡和文件播放链路不用改。public sealed class WavRecorder : IDisposable { private readonly FileStream _fs; private readonly BinaryWriter _writer; private readonly int _sampleRate; private readonly short _channels; private long _dataBytes; public WavRecorder(string path, int sampleRate 44100, short channels 2) { _sampleRate sampleRate; _channels channels; _fs new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); _writer new BinaryWriter(_fs, Encoding.ASCII, leaveOpen: true); WriteHeader(0); } public void Write(byte[] pcm, int offset, int count) { _fs.Write(pcm, offset, count); _dataBytes count; } private void WriteHeader(int dataSize) { _fs.Position 0; _writer.Write(Encoding.ASCII.GetBytes(RIFF)); _writer.Write(36 dataSize); _writer.Write(Encoding.ASCII.GetBytes(WAVEfmt )); _writer.Write(16); _writer.Write((short)1); _writer.Write(_channels); _writer.Write(_sampleRate); _writer.Write(_sampleRate * _channels * 2); _writer.Write((short)(_channels * 2)); _writer.Write((short)16); _writer.Write(Encoding.ASCII.GetBytes(data)); _writer.Write(dataSize); } public void Dispose() { WriteHeader((int)_dataBytes); // 结束时回填真实数据长度 _writer.Dispose(); _fs.Dispose(); } }逻辑说明构造时先写 44 字节 WAV 头数据长度填 0播放过程中只追加 PCM 数据并累计_dataBytesDispose时回到文件头重写长度。这样即使录制中途异常文件至少还有头能拿工具修复。参数说明sampleRate和channels必须和解码器输出一致不一致会出现变速或声道错位dataSize用intWAV 单文件超过 4GB 要分卷长时间录制要按小时切文件FileShare.Read允许录制时另一个工具读取方便边录边检查。若源码里已有转码模块也可以录成压缩格式但不要在播放回调里做重编码容易拖慢音频线程。4.2 定时任务与休眠唤醒定时播放的坑不在“到点调用播放”而在“到点只触发一次”和“休眠唤醒后补触发”。用PeriodicTimer每 30 秒检查一次比Timer回调里做异步更稳至少不会因为上一次播放没结束而重入。public sealed class ScheduleRunner { private readonly FuncDateTimeOffset, Station? _pick; private readonly FuncStation, Task _play; private string? _lastFireKey; public async Task RunAsync(CancellationToken ct) { using var timer new PeriodicTimer(TimeSpan.FromSeconds(30)); while (await timer.WaitForNextTickAsync(ct)) { var now DateTimeOffset.Now; var station _pick(now); if (station is null) continue; var key ${now:yyyyMMddHHmm}:{station.Id}; if (key _lastFireKey) continue; // 同一分钟只触发一次 _lastFireKey key; await _play(station); } } }逻辑说明_pick根据当前时间返回要播的电台返回空表示这一分钟没有任务_lastFireKey用“分钟 电台 ID”去重避免 30 秒轮询在同一分钟内触发两次_play是异步播放不会阻塞轮询。参数说明轮询间隔 30 秒对分钟级定时够用如果要做秒级定时间隔要降到 1 秒但去重键也要跟着改休眠唤醒后PeriodicTimer可能一次性补发_lastFireKey能挡住重复触发但错过的那一分钟不会补播需要的话在启动时单独检查一次。4.3 音量归一和静音检测换台时音量忽大忽小是网络电台很常见的问题。简单做法是在 PCM 写入声卡前做峰值归一扫描一段 PCM 的峰值按目标峰值算增益再逐样本乘增益。增益要设上限否则把底噪也放大。public static void NormalizePcm16(byte[] pcm, int offset, int count, short targetPeak 24000) { short peak 0; for (var i offset; i 1 offset count; i 2) { var s BitConverter.ToInt16(pcm, i); var abs s short.MinValue ? short.MaxValue : Math.Abs(s); if (abs peak) peak abs; } if (peak 0) return; var gain Math.Min(4.0, targetPeak / (double)peak); // 最多放大 4 倍 for (var i offset; i 1 offset count; i 2) { var s BitConverter.ToInt16(pcm, i); var v (int)Math.Round(s * gain); v Math.Clamp(v, short.MinValue, short.MaxValue); pcm[i] (byte)(v 0xFF); pcm[i 1] (byte)((v 8) 0xFF); } }逻辑说明第一遍扫峰值第二遍按增益重写 16bit 样本short.MinValue取绝对值会溢出单独处理Math.Clamp防止乘完增益后削波。静音检测可以复用第一遍扫描峰值低于阈值就标记为静音界面上显示“无信号”不要直接停掉播放。参数说明targetPeak默认 24000离short.MaxValue留一点余量增益上限 4 倍再高会把低码率流的噪声推出来扫描块 16KB 对应约 185ms 双声道音频块太大归一反应慢块太小增益变化频繁。实际项目里可以按 2 到 3 秒窗口算一次增益再做平滑避免每个块都变。5. 避坑与排查C# 网络收音机源码最常见的五处翻车这类源码跑不起来很多时候不是缺某个大模块而是几个参数和线程边界没对齐。下面五条是我拆类似项目时遇到频率最高的按“现象 → 原因 → 解决”写方便对着日志逐条排。5.1 连上就断缓冲区与超时参数现象是点播放后能听到一两秒声音随后停止日志里出现IOException或TaskCanceledException。原因通常是缓冲时长太短或者HttpClient.Timeout设得太小。网络收音机是长连接服务端不会很快返回完整内容如果代码用默认 100 秒超时看起来不是这个问题但有些源码会把超时改成 3 秒遇到稍微慢一点的端点就断。解决是把单次连接超时和读超时分开连接阶段给 10 到 20 秒读阶段用Timeout.InfiniteTimeSpan配合CancellationToken缓冲从 500ms 起步弱网调到 1200ms。重试不要无限循环4 到 5 次后提示用户换台。5.2 只有杂音采样率与声道数现象是能连上、有声音但全是杂音或变速。原因多半是把 MP3 原始字节直接写给了 PCM 输出或者解码器输出 48000Hz 而声卡按 44100Hz 打开。解决是先把播放链路固定成一种 PCM 格式44100Hz、16bit、双声道解码器输出不一致时加一层重采样录音模块和音量归一都只处理这种格式。排查时可以在_sink.Start前打印SampleRate和Channels再看 WAV 头里的字段是否一致。别用“能听到人声”判断正常杂音里也可能有人声。5.3 界面卡死UI 线程与音频回调现象是播放开始后窗口拖不动或者换台时界面白屏。原因是播放循环里用了.Result、.Wait()或者在音频回调里直接更新控件。解决是把播放循环放到后台任务UI 更新走Dispatcher.InvokeAsync或SynchronizationContext.Postawait后面加ConfigureAwait(false)避免把后续代码拉回 UI 线程。音频回调里只做数据拷贝不要写日志、不要弹窗、不要查数据库。日志用队列异步落盘回调里最多记一个计数器。5.4 列表能播电脑不能播编码与相对路径现象是同一个 M3U 在别的播放器里能用在这份源码里解析出来是空列表或乱码。原因常见两个M3U 文件不是 UTF-8带 BOM 或本地编码列表里是相对路径代码直接new Uri(line)失败。解决是读文件时先检测 BOM再用 UTF-8 严格解码失败时回退到本地编码解析出的地址用列表文件所在目录做Uri基地址转成绝对地址嵌套列表最多递归两层并记录已访问 URL避免循环引用。5.5 长时间运行内存涨事件解绑与缓冲回收现象是开一晚上内存从几十兆涨到几百兆换台越多次越明显。原因通常是每次换台都新建HttpClient、事件订阅没解绑、PCM 缓冲反复分配大数组。解决是HttpClient做成单例或按宿主生命周期复用换台时先取消旧CancellationTokenSource再解绑旧解码器和IAudioSink的事件缓冲用ArrayPoolbyte.Shared租还回调里不要new byte[1024 * 1024]。排查时看句柄数和 GC 代际别只看任务管理器的工作集。6. 把源码跑成自己的工具三个验证动作和二次开发切口这份源码要变成自己的工具不建议一上来改界面。更省事的路径是把配置作为唯一入口让播放器只认RadioConfig换台、改缓冲、改音量都不重新编译。下面这段就是启动时从配置恢复上次电台的写法var json File.ReadAllText(radio.json, Encoding.UTF8); var cfg JsonSerializer.DeserializeRadioConfig(json)!; var station cfg.Stations.FirstOrDefault(s s.Id cfg.LastStationId) ?? cfg.Stations.FirstOrDefault(); if (station is not null) { var player new PlaybackController(sink, decoder); await player.PlayAsync(new Uri(station.Url), CancellationToken.None); }逻辑说明配置里存LastStationId启动时优先恢复上次电台找不到就退回列表第一项BufferMs和Volume可以在创建sink和decoder时读入避免播放中途改参数导致状态不一致。参数上radio.json用 UTF-本文还有配套的精品资源点击获取