0. 开个新系列:趣味项目
工作、生活、课程设计中,我们总会冒出一些奇奇怪怪的点子——它们未必实用,却足够有趣,完全可以用代码把它们实现出来。
这个系列,就是为那些"爱玩又爱折腾"的童鞋准备的。别让那些奇思妙想只停留在脑海里,动手把它写出来,那才是正经事呢。
第一篇,先从一个很接地气的点子开始:随机播放一首歌,让大家来猜歌名。触发的条件有两种:一个是按钮点击播放,另一个是手机触碰NFC读卡器播放。
1. 描述下需求
在 Windows 上选好本地音乐目录;手机或 NFC 标签靠近读卡器时,程序随机挑一首播放。歌名先隐藏,等大家猜好后,点击按钮显示歌名,公布答案。
2. 整体规划
程序是一个 Windows 桌面应用(WPF + .NET 9),逻辑可以简单画成这样:
手机/标签 ──碰──> USB NFC 读卡器 │ ▼ NfcWatcher(PC/SC 监听) │ 防抖 / UID 校验 ▼ PlaybackService(随机选歌 + 播放) │ ▼ 主界面 / 托盘(封面、进度、猜歌模式)几个模块分工:
- MusicLibrary:扫目录,收集 mp3 / wav / flac / m4a 等
- NfcWatcher:盯着 PC/SC 读卡器,卡片出现就读 UID
- PlaybackService:随机抽歌、控制暂停停止、读进度
- TrayService:最小化到托盘,后台继续待命
3. 简单说说NFC
NFC(Near Field Communication,近场通信)就是一种让两个设备靠近到几厘米内就能交换数据的短距离无线技术。目前绝大多数手机都自带NFC功能。
本次用它是一个小亮点,手机刷一下,或者NFC卡片刷一下,就触发播放,对于一些场景来说是很酷的事情。
在 Windows 上,常见 USB的NFC读卡器(比如 ACR122U)走的是PC/SC标准:系统把读卡器当「智能卡读卡器」来管,程序用库去监听「有没有卡靠近」。
本项目用的是PCSC/PCSC.Iso7816这一套。NfcWatcher干的活可以概括成:
- 枚举读卡器
- 监听卡片插入 / 出现
- 发 APDU 读 UID(手机当 NFC 标签贴近时,一般也能读到)
- 防抖:同一张卡短时间内重复触发,直接忽略
防抖为什么重要?因为「贴一下」在硬件层常常是连续多次事件。没有防抖,我们的程序就会像机关枪一样连发触碰播放。
4. 再来说说随机播放
「随机」听起来简单:Random抽一首就行。但是细节是魔鬼,我们在实现中要注意:
- 刚播过的歌又抽到:体感很差,像假随机
- 格式五花八门:mp3、flac、m4a,播放器要尽量都支持
所以PlaybackService里做了个策略:记最近若干首路径,抽到「刚播过」的就再抽。播放层用NAudio。
界面上还有个开关:显示歌名 / 隐藏歌名。
- 显示:正常听歌,封面、标题、歌手都显示
- 隐藏:标题变成「??? 猜歌中」,封面也藏起来
5. NAudio 简介
在 .NET 桌面端,本地音频播放常见方案里,NAudio是用得最多的开源库之一。
简单理解成三层:
| 概念 | 在本项目里对应 | 干什么 |
|---|---|---|
AudioFileReader | 读文件 | 打开 mp3/wav/flac 等,解码成 PCM,还能设音量、Seek |
IWavePlayer(本项目用WaveOutEvent) | 播出去 | 把 PCM 推给声卡 |
PlaybackStopped事件 | 播完回调 | 区分「自然播完」和「手动 Stop」 |
最小播放代码如下:
usingvarreader=newAudioFileReader(@"D:\music\demo.mp3"){Volume=0.8f};usingvarplayer=newWaveOutEvent();player.Init(reader);player.Play();就这几行,一首歌就能播放出来。本项目的PlaybackService本质上就是在这个骨架外增加了:随机选曲、最近播放去重、暂停/停止、进度条 Seek、以及线程安全的状态上报。
为什么不直接用系统自带的MediaPlayer?相比NAudio,在格式支持、Seek、音量、播完事件这些细节上,NAudio 更可控些,所以我选择了它。
6. 核心逻辑代码
路径都在src/RandomSongNfc/Services/下。
6.1 扫目录:MusicLibrary
先把本地目录扫成曲目列表。扩展名白名单 + 递归扫描:
privatestaticreadonlyHashSet<string>SupportedExtensions=new(StringComparer.OrdinalIgnoreCase){".mp3",".wav",".flac",".m4a",".aac",".wma",".ogg"};varfiles=Directory.EnumerateFiles(directory,"*.*",SearchOption.AllDirectories).Where(path=>SupportedExtensions.Contains(Path.GetExtension(path))).OrderBy(path=>path,StringComparer.OrdinalIgnoreCase).ToList();每首歌再用 TagLibSharp 读标题、歌手、封面;读失败就回退到文件名。
6.2 随机抽歌:
PlayRandom()的核心不是Next()本身,而是最近播放队列:
privatereadonlyQueue<string>_recentPaths=new();privateconstintRecentHistorySize=10;publicTrackInfoPlayRandom(){vartracks=_library.Tracks;// 先排除最近播过的varcandidates=tracks.Where(t=>!_recentPaths.Contains(t.FilePath)).ToList();// 全排除光了?清空历史if(candidates.Count==0){candidates=tracks.ToList();_recentPaths.Clear();}varpick=candidates[_random.Next(candidates.Count)];Play(pick);returnpick;}短时间内不太容易抽到刚听过的那几首;曲库播一轮「新鲜的」之后,再自然放开。
6.3 播放:AudioFileReader + WaveOutEvent
切歌时先暂停,后播放新歌,并且用锁保护——UI 线程、定时器、NFC 回调都可能碰播放器:
publicvoidPlay(TrackInfotrack){lock(_gate){StopInternal();_reader=newAudioFileReader(track.FilePath){Volume=_volume};_player=newWaveOutEvent();_player.Init(_reader);_player.PlaybackStopped+=OnPlaybackStopped;_current=track;_player.Play();_recentPaths.Enqueue(track.FilePath);while(_recentPaths.Count>RecentHistorySize)_recentPaths.Dequeue();}RaiseStatusChanged();}暂停/继续:
if(_player.PlaybackState==NAudio.Wave.PlaybackState.Playing)_player.Pause();elseif(_player.PlaybackState==NAudio.Wave.PlaybackState.Paused)_player.Play();6.4 NFC 触发:插卡 → 读 UID → 防抖 → 通知播放
监听用 PC/SC 的CardInserted。贴卡后先读 UID(常见的FF CA 00 00取 UID APDU),再决定接不接受这次触碰:
privatevoidOnCardInserted(objectsender,CardStatusEventArgsargs){varuid=TryReadUid(args.ReaderName)??"UNKNOWN";LastUid=uid;if(!ShouldAcceptTap(uid))return;TapDetected?.Invoke(this,newNfcTapEventArgs{ReaderName=args.ReaderName,Uid=uid});}防抖 + 可选白名单在ShouldAcceptTap:
// 未授权直接丢if(requireAuth&&!authorized.Contains(NormalizeUid(uid)))returnfalse;// 同一 UID 在防抖窗口内重复到达 → 忽略if((now-lastTap).TotalMilliseconds<debounceMs&&string.Equals(lastUid,uid,StringComparison.OrdinalIgnoreCase))returnfalse;主窗口收到TapDetected后,再调PlayRandom()。
读 UID 的 APDU(PC/SC 读卡器上很常见):
varapdu=newCommandApdu(IsoCase.Case2Short,isoReader.ActiveProtocol){CLA=0xFF,Instruction=(InstructionCode)0xCA,// GET DATAP1=0x00,P2=0x00,Le=0x00};varresponse=isoReader.Transmit(apdu);成功就把返回字节格式化成十六进制 UID 字符串。
7. 怎么用?
硬件侧:
- 准备一台 Windows 10/11 x64 PC
- 插上 USB NFC 读卡器,装好驱动(设备管理器里应出现「智能卡读卡器」)
- 下载发布包,直接运行
RandomSongNfc.exe(自包含单文件,不用再装 .NET)
如果没有也不要紧,就直接手动点击按钮来播放。
软件侧:
- 点「浏览…」选本地音乐目录
- 顶部小圆点变绿 = 读卡器已连接
- 手机背面或 NFC 标签贴一下读卡器 → 随机开播
- 想常驻:关窗口会进托盘,右键托盘图标可退出
其他:
- 勾选「播完自动随机下一首」:随机播放器了属于是
- 勾选「播放中忽略 NFC」:听歌时别人乱碰也不切歌,嗯,不播放完不能打断
8. 最后
如果你在移动办公中遇到扫描场景,可以使用我的小程序「程序员Linc表格识别」,主打高清免费无水印的扫描,最新版本的扫描功能已经支持了导出增强锐化、黑白、加深、灰度等PDF文件,上去就可以用,嘎嘎好使。欢迎大家试用。
本趣味项目的开源仓库在这里,欢迎做二创:
https://gitee.com/lincyang/random-song-playback-via-nfc
如果想直接获取exe文件:
# 网盘获取 公众号聊天输入:猜歌名欢迎拿走用、改、提建议。有其他趣味项目选题,请在评论区讨论!
#趣味项目 #NFC #随机播放 #WPF #dotnet #课程设计