news 2026/11/5 4:59:13

C#网络收音机源码解析:HTTP音频流、解码后端与播放链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#网络收音机源码解析:HTTP音频流、解码后端与播放链路

简介:网络收音机源代码是一份基于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/mpeg,HttpClient拿到的是一条长连接,读到的是连续音频字节。第二类是播放列表,URL 以.m3u、.pls结尾,返回的是文本,里面可能列了多个候选地址,也可能嵌套一层列表。第三类是分片流,.m3u8只是索引文件,真正播放要按索引去拉.ts或.m4s分片,再按顺序拼接。把第三类当第二类解析,是这类源码里很典型的翻车点:列表解析出来了,播放器却把#EXTINF当成音频地址。

我一般不会只靠扩展名判断,而是先发一个带Range的 GET,只读前 4KB,看Content-Type和文本开头。这样比 HEAD 更可靠,因为有些流媒体端点对 HEAD 支持不完整。代码骨架如下:

public enum StreamKind { Direct, Playlist, Segmented, Unknown } public static async Task<StreamKind> 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 IEnumerable<Station> 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 IEnumerable<Station> ParsePls(string text) { var files = new SortedDictionary<int, string>(); var titles = new SortedDictionary<int, 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分别收进有序字典,再按编号合并,避免文件顺序和标题顺序错位。两个方法都返回IEnumerable<Station>,调用方可以用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 Task<string?> 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 List<Station> 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 数据并累计_dataBytes;Dispose时回到文件头重写长度。这样即使录制中途异常,文件至少还有头,能拿工具修复。

参数说明:sampleRate和channels必须和解码器输出一致,不一致会出现变速或声道错位;dataSize用int,WAV 单文件超过 4GB 要分卷,长时间录制要按小时切文件;FileShare.Read允许录制时另一个工具读取,方便边录边检查。若源码里已有转码模块,也可以录成压缩格式,但不要在播放回调里做重编码,容易拖慢音频线程。

4.2 定时任务与休眠唤醒

定时播放的坑不在“到点调用播放”,而在“到点只触发一次”和“休眠唤醒后补触发”。用PeriodicTimer每 30 秒检查一次,比Timer回调里做异步更稳,至少不会因为上一次播放没结束而重入。

public sealed class ScheduleRunner { private readonly Func<DateTimeOffset, Station?> _pick; private readonly Func<Station, 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.Post;await后面加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的事件;缓冲用ArrayPool<byte>.Shared租还,回调里不要new byte[1024 * 1024]。排查时看句柄数和 GC 代际,别只看任务管理器的工作集。

6. 把源码跑成自己的工具:三个验证动作和二次开发切口

这份源码要变成自己的工具,不建议一上来改界面。更省事的路径是把配置作为唯一入口,让播放器只认RadioConfig,换台、改缓冲、改音量都不重新编译。下面这段就是启动时从配置恢复上次电台的写法:

var json = File.ReadAllText("radio.json", Encoding.UTF8); var cfg = JsonSerializer.Deserialize<RadioConfig>(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-

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 4:59:13

2分钟极速接入Claude Opus 5.5:AI Gateway与CLI实战指南

1. 为什么“2分钟接入”这件事值得单独拿出来讲先把结论摆在前面&#xff1a;Claude Opus 5.5 这类模型本身不难用&#xff0c;难的是“接入”这一步。我见过太多人卡在环境变量、CLI 路径、网关鉴权、代理配置这些环节上&#xff0c;模型还没跑起来&#xff0c;人已经先烦了。…

作者头像 李华
网站建设 2026/11/5 4:58:36

从轮询到长连接:在线人数统计的完整技术方案与避坑指南

做站点运维或者独立开发的人&#xff0c;几乎都会在某个阶段冒出一个念头&#xff1a;现在到底有多少人正在看我的页面&#xff1f;这个需求听起来简单&#xff0c;真动手做的时候却会发现坑一个接一个——统计口径怎么定、数据存在哪、页面关掉的那一刻怎么通知服务端、多标签…

作者头像 李华
网站建设 2026/11/5 4:58:25

解决VS Code Remote-SSH管道不存在错误的系统方法

1. 问题现象与背景分析最近在配置VS Code Remote-SSH插件连接远程服务器时&#xff0c;遇到了一个典型的错误提示&#xff1a;"过程试图写入的管道不存在&#xff08;The process tried to write to a nonexistent pipe&#xff09;"。这个错误通常发生在SSH连接建立…

作者头像 李华
网站建设 2026/11/5 4:57:46

数字时代品牌进化:技术驱动与敏捷迭代

1. 品牌与时代的共生关系2007年iPhone横空出世时&#xff0c;诺基亚高管曾嘲笑"没人会花500美元买一部手机"。这个商业史上的经典案例&#xff0c;揭示了品牌与时代关系的本质——不是时代在淘汰品牌&#xff0c;而是品牌自己选择了落后。当代商业环境中&#xff0c;…

作者头像 李华
网站建设 2026/11/5 4:57:44

重庆多智能体系统实践与AI Agent技术解析

1. 重庆AI Agent技术发展现状观察重庆作为西南地区重要的经济中心&#xff0c;近年来在人工智能领域展现出强劲的发展势头。从两江新区的数字经济产业园到高新区的智能产业基地&#xff0c;AI Agent技术正在这里快速落地生根。我走访了当地十余家科技企业&#xff0c;发现一个有…

作者头像 李华
网站建设 2026/11/5 4:57:17

BAS-BP混合模型在电力负荷预测中的优化应用

1. 项目背景与核心价值电力负荷预测是电力系统运行调度中的关键技术环节。传统BP神经网络在解决这类问题时常常陷入局部最优、收敛速度慢等困境。而天牛须算法&#xff08;BAS&#xff09;作为一种新兴的仿生优化算法&#xff0c;其独特的嗅觉搜索机制恰好能弥补BP网络的缺陷。…

作者头像 李华