从零打造商场背景音乐与紧急广播系统(二):技术选型与架构

一、先定三条底线

需求聊清楚后,我列了三条技术底线,后续所有选型都不能违背。

底线一:必须单机运行。

商场内网,不能依赖公网。断网了,背景音乐要照放,找人要照播,消防联动要照触发。云广播 SaaS 直接排除。

底线二:必须访问声卡。

多分区独立输出,Web 做不到,Electron 通过 Node 也能做但延迟和稳定性不如原生。必须用桌面技术。

底线三:必须能常驻后台。

商场营业 12 小时,软件要无人值守。Windows Service 是标配,不能依赖用户手动打开界面。

三条底线一划,技术路线基本就定了。


二、为什么是 WPF + NAudio

为什么不用 Electron

Electron 看起来很美:Web 技术栈,界面漂亮,跨平台。

但商场场景有几个致命问题:

 
问题说明
多声卡输出 Electron 通过 Node 调用系统音频,多设备切换不如原生稳定
音频延迟 Chromium 音频栈有额外缓冲,找人播报要求低延迟
内存占用 一个 Electron 应用至少 200MB,常驻不划算
后台运行 Electron 依赖主窗口,最小化到托盘体验差
安装包 至少 100MB 起步,用户下载意愿低

结论:Electron 适合做"看起来像桌面应用的 Web 应用",不适合做"真正调用系统能力的工具"。

为什么不用 WinForms

WinForms 更简单,但:

  • 主题和动画:深色主题、圆角卡片、微动效,WinForms 做起来痛苦十倍。

  • 数据绑定:MVVM 让 UI 和逻辑解耦,WinForms 靠事件和手动赋值,代码容易乱。

  • 高 DPI:WinForms 需要额外配置,WPF 原生支持。

  • 长期维护:WPF 是微软主推的 Windows 桌面框架,WinForms 维护模式。

结论:如果只做单机简单播放器,WinForms 也行。但要做成产品,WPF 更合适。

为什么是 NAudio

音频库的选择不多:

 
库评价
NAudio 成熟稳定,多声卡支持好,社区活跃
BASS 商业授权,功能强大但收费
CSCore 已停止维护
Windows Media Player API 老旧,多声卡支持差
SoundPlayer(BCL) 只能播 WAV,无法多声卡

NAudio 的核心优势:

  1. 多声卡支持:WaveOutEvent.DeviceNumber 直接指定输出设备。

  2. 格式支持:MP3、WAV、AIFF 原生,M4A/AAC 通过 MediaFoundation。

  3. 淡入淡出:FadeInOutSampleProvider 开箱即用。

  4. 音量控制:VolumeSampleProvider 支持分区独立调节。

  5. 免费开源:MIT 协议,商用无忧。

结论:NAudio 是商场广播场景的最优解。

TTS 选型

紧急找人的核心是"文字转语音",选型对比:

 
方案音质费用离线
Edge TTS 优秀 免费 需要联网
Azure TTS 最佳 按量付费 需要联网
System.Speech 一般 免费 离线
讯飞 TTS 优秀 按量付费 需要联网

结论:主用 Edge TTS(免费、音质好),离线时降级到 System.Speech。


三、架构分层:一次痛苦但值得的重构

第一版:一团乱麻

最初的项目结构很简单:

MallBroadcast/
├── MainWindow.xaml.cs     # 所有逻辑都在这
├── Program.cs
└── AudioPlayer.cs

MainWindow.xaml.cs 有 3000 行:定时、播放、配置、UI、TTS 全混在一起。改一个 bug 引发三个新 bug。想加 TTS,发现要动的地方太多,怕改坏。

第二版:分层清晰

痛定思痛,花了一周重构:

MallBroadcast.sln
├── src/
│   ├── MallBroadcast.Abstractions/       # 契约层:接口、DTO、事件(零依赖)
│   ├── MallBroadcast.Core/               # 核心实现:调度、播放、分区、优先级
│   ├── MallBroadcast.App/                # WPF 主程序
│   ├── MallBroadcast.Service/            # Windows Service 宿主
│   ├── MallBroadcast.Api/                # ASP.NET Core 类库(手机端调用)
│   └── MallBroadcast.Plugin.Sample/      # 示例插件
└── tests/
    ├── MallBroadcast.Core.Tests/
    ├── MallBroadcast.Audio.Tests/
    └── MallBroadcast.Integration.Tests/

 

依赖方向严格单向

Abstractions ← Core ← App/Service/Api
Abstractions ← Plugin.Sample
Abstractions ← Tests

规则:

  • Abstractions 不引用任何项目

  • Core 只引用 Abstractions

  • App/Service/Api 引用 Core + Abstractions

  • Plugin 只引用 Abstractions

  • 禁止反向引用

为什么要有 Abstractions

这是重构中最关键的一层。

问题:如果插件直接引用 Core,插件就会带上 Core 的所有依赖(NAudio、NLog、MiniExcel),体积大、版本冲突风险高。

解决:把所有接口抽到 Abstractions,插件只引用 Abstractions。

// Abstractions/Audio/IPlaybackService.cs
public interface IPlaybackService
{
    Task PlayAsync(PlaybackRequest request, CancellationToken ct = default);
    Task StopAsync(string zoneId, CancellationToken ct = default);
    Task StopAllAsync(CancellationToken ct = default);
}

// Abstractions/Events/IPlaybackEvent.cs
public class PlaybackStartedEvent
{
    public string ZoneId { get; set; }
    public string ProgramId { get; set; }
    public PlaybackPriority Priority { get; set; }
    public DateTime StartTime { get; set; } = DateTime.Now;
}

Core 实现这些接口,App/Service/Api 通过 DI 注入使用。

收益:

  • 插件零依赖,体积小

  • App/Service/Api 共享契约

  • 未来换实现(如 JSON 换 SQLite)不影响上层

  • 测试可以 Mock 接口,不依赖真实实现

各层职责

 
项目职责关键点
Abstractions 定义契约 零依赖,只有接口、DTO、事件、枚举
Core 业务实现 调度、播放、分区、优先级、TTS、配置
App WPF UI 只调用 ICoreService,不写业务逻辑
Service Windows Service 宿主 托管调度、播放、Api(可选)
Api REST API 只调用 ICoreService,不直接操作文件或音频
Plugin.Sample 示例插件 只依赖 Abstractions

这次重构值不值

花了一周,但后期开发效率提升三倍:

  • 加 TTS:只改 Core 和 App,不影响 Service

  • 加优先级:只改 Core,接口不变

  • 加手机端:Api 只调 ICoreService,一行业务逻辑都不用写

  • 加插件:只引用 Abstractions,编译快、体积小

结论:重构痛苦一周,收益持续几个月。值。


四、核心接口设计

播放服务

public interface IPlaybackService
{
    Task PlayAsync(PlaybackRequest request, CancellationToken ct = default);
    Task StopAsync(string zoneId, CancellationToken ct = default);
    Task StopAllAsync(CancellationToken ct = default);
    ZonePlaybackState GetState(string zoneId);
}

调度服务

public interface IScheduleService
{
    Task StartAsync(CancellationToken ct);
    Task StopAsync(CancellationToken ct);
    IReadOnlyList<ScheduleTime> GetTodaySchedules();
    Task ApplyScheduleSetAsync(string scheduleSetId, CancellationToken ct);
}

TTS 服务

public interface ITtsService
{
    Task<string> SynthesizeAsync(string text, TtsOptions options, CancellationToken ct = default);
}

事件总线

public interface IEventBus
{
    void Publish<TEvent>(TEvent @event) where TEvent : class;
    void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
    void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
}

核心服务(唯一入口)

public interface ICoreService
{
    // 播放
    Task PlayProgramAsync(string programId, string zoneId, PlaybackPriority priority = PlaybackPriority.Scheduled);
    Task StopAllAsync();

    // 查询
    IReadOnlyList<AudioZone> GetZones();
    IReadOnlyList<Program> GetPrograms();

    // 作息表
    Task ApplyScheduleSetAsync(string scheduleSetId);
}

App、Service、Api 都通过 ICoreService 调用业务逻辑,不绕过它。


五、核心模型

// 节目
public class Program
{
    public string Id { get; set; }
    public string Name { get; set; }
    public ProgramType Type { get; set; }           // AudioFile / TtsTemplate
    public string AudioPath { get; set; }
    public string TtsTemplate { get; set; }         // TTS 模板
    public PlayMode Mode { get; set; }              // Single / FolderSequence / FolderRandom
    public bool FadeOutAtEnd { get; set; }
    public int FadeOutSeconds { get; set; } = 3;
    public double Volume { get; set; } = 1.0;
    public PlaybackPriority Priority { get; set; } = PlaybackPriority.Scheduled;
}

// 播出时间点
public class ScheduleTime
{
    public string Id { get; set; }
    public string ProgramId { get; set; }
    public TimeSpan Time { get; set; }
    public RepeatMode Repeat { get; set; }          // Once / Daily / Weekly / Monthly
    public DayOfWeek[] DaysOfWeek { get; set; }
    public DateTime? ValidFrom { get; set; }
    public DateTime? ValidTo { get; set; }
    public bool Enabled { get; set; } = true;
    public PlaybackPriority Priority { get; set; } = PlaybackPriority.Scheduled;
}

// 分区
public class AudioZone
{
    public string Id { get; set; }
    public string Name { get; set; }                // 一楼精品区
    public int DeviceNumber { get; set; }           // 声卡编号
    public string DeviceName { get; set; }          // 设备名(防编号漂移)
    public double Volume { get; set; } = 1.0;
    public bool Enabled { get; set; } = true;
}

// 播放请求
public class PlaybackRequest
{
    public string RequestId { get; set; } = Guid.NewGuid().ToString();
    public string ProgramId { get; set; }
    public string ZoneId { get; set; }
    public PlaybackPriority Priority { get; set; } = PlaybackPriority.Scheduled;
    public DateTime RequestTime { get; set; } = DateTime.Now;
    public bool AllowDucking { get; set; } = true;
}

 


六、依赖注入与生命周期

用 Microsoft.Extensions.DependencyInjection 统一管理:

// App.xaml.cs
services.AddSingleton<ICoreService, CoreService>();
services.AddSingleton<IPlaybackService, PlaybackService>();
services.AddSingleton<IScheduleService, ScheduleService>();
services.AddSingleton<IEventBus, EventBus>();
services.AddSingleton<ILicenseService, LicenseService>();
services.AddSingleton<ITtsService, EdgeTtsService>();
services.AddSingleton<IAudioDeviceService, NAudioDeviceService>();
services.AddSingleton<IExcelExportService, ExcelExportService>();
services.AddSingleton<ILoggerFactory, LoggerFactory>();

生命周期选择:

 
服务生命周期理由
ICoreService Singleton 全局唯一
IPlaybackService Singleton 持有播放状态
IScheduleService Singleton 后台常驻
IEventBus Singleton 全局事件总线
ILicenseService Singleton 许可证只加载一次
ITtsService Singleton 缓存复用
ViewModel Transient 每次新建
View Singleton 主窗口唯一

七、配置管理

配置结构

{
  "version": "1.1",
  "zones": [
    { "id": "z1", "name": "一楼精品区", "deviceNumber": 1, "deviceName": "USB Audio #1", "volume": 0.9 },
    { "id": "z2", "name": "二楼餐饮区", "deviceNumber": 2, "deviceName": "USB Audio #2", "volume": 0.85 },
    { "id": "z3", "name": "三楼母婴区", "deviceNumber": 3, "deviceName": "USB Audio #3", "volume": 0.8 },
    { "id": "z4", "name": "地下车库", "deviceNumber": 4, "deviceName": "USB Audio #4", "volume": 0.7 }
  ],
  "programs": [
    {
      "id": "p1", "name": "背景音乐-精品区", "type": "AudioFile",
      "audioPath": "Audio/精品区/", "mode": "FolderRandom",
      "fadeOutAtEnd": true, "volume": 0.8, "priority": "Background"
    }
  ],
  "scheduleSets": [
    {
      "id": "s1", "name": "工作日", "isActive": true,
      "times": [
        { "id": "t1", "programId": "p1", "time": "10:00:00", "repeat": "Weekly",
          "daysOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"], "enabled": true }
      ]
    }
  ],
  "features": { "tts": true, "priority": true, "multiZone": true, "webApi": false },
  "priority": {
    "behavior": { "Emergency": "Interrupt", "Manual": "Duck", "Scheduled": "Duck", "Background": "Queue" },
    "duckVolume": 0.2,
    "fadeOutMs": 300
  }
}

 

版本迁移

配置带 version 字段,启动时自动迁移:

public interface IConfigMigration
{
    int FromVersion { get; }
    int ToVersion { get; }
    void Migrate(JsonNode config);
}

 

每加一个功能就升版本,写好迁移器。 老用户升级后配置不丢。

原子写入

直接 File.WriteAllText 有风险:写到一半断电,配置损坏。改用原子写入:

var tempFile = filePath + ".tmp";
File.WriteAllText(tempFile, json);
File.Replace(tempFile, filePath, null);

防抖

用户连续操作 UI 时,可能触发多次保存。加防抖:

private CancellationTokenSource _saveCts;

public void SaveDebounced()
{
    _saveCts?.Cancel();
    _saveCts = new CancellationTokenSource();
    Task.Delay(500, _saveCts.Token).ContinueWith(t =>
    {
        if (!t.IsCanceled) Save();
    });
}

500ms 内的多次保存合并为一次。


八、日志

用 NLog,但业务代码只用 ILogger<T> 抽象:

public class PlaybackService
{
    private readonly ILogger<PlaybackService> _logger;

    public PlaybackService(ILogger<PlaybackService> logger) => _logger = logger;

    public async Task PlayAsync(PlaybackRequest request, CancellationToken ct)
    {
        _logger.LogInformation("开始播放 {ProgramId} 到分区 {ZoneId},优先级 {Priority}",
            request.ProgramId, request.ZoneId, request.Priority);
        // ...
    }
}

NLog 配置:异步写入、分级输出、文件滚动、keepFileOpen="true"。

<targets async="true">
  <target name="file" xsi:type="File"
          fileName="Logs/${shortdate}.log"
          archiveAboveSize="10485760"
          maxArchiveFiles="30"
          keepFileOpen="true"
          encoding="utf-8"/>
</targets>
<rules>
  <logger name="*" minlevel="Info" writeTo="file"/>
  <logger name="MallBroadcast.Scheduling.*" minlevel="Debug" writeTo="file"/>
</rules>

 


九、产品截图

4bbcdd985e82347389551ed1b5d33cf5

 

[图 1:主界面整体布局]
截图说明:深色主题,顶部工具栏(作息表切换、今日调整、立即播放、暂停今日、设置、紧急广播),左侧功能导航(定时播放、节目库、分区管理、作息表、节假日、设置、日志),中间是今日任务列表,右侧是运行状态面板(当前播放、优先级、下一任务、总音量、分区状态),底部状态栏显示运行状态、声卡数量、已执行任务数、版本号。

界面分五个区域:

  • 顶部工具栏:作息表切换、今日调整、立即播放、暂停今日、设置、紧急广播

  • 左侧导航:功能入口

  • 中间工作区:任务列表(今日视图、列表视图、周视图、时间轴视图)

  • 右侧状态面板:当前播放、优先级、下一任务、总音量、分区状态

  • 底部状态栏:运行状态、声卡数、今日执行数、版本

fd1ba52ae08cb6684690b6f3b2b6b16d

 

[图 2:首次启动向导]
截图说明:首次打开软件时,选择使用场景(幼儿园、学校、商场/超市、工厂/车间、军营、消防站),自动创建示例配置,降低上手门槛。

首次启动向导解决"新用户不知道从哪开始"的问题。选择场景后,自动创建示例节目、时间点、分区,用户只需微调即可使用。

22fdb3fd3f726bb7f2feebaf86db8052

 

[图 3:浅色主题主界面]
截图说明:同一界面切换到浅色主题,背景变白,文字变深色,主色和状态色保持一致。顶部工具栏有主题切换按钮。

主题切换通过 ResourceDictionary 实现,所有颜色用 DynamicResource 绑定,运行时替换即可。


十、项目结构一览

MallBroadcast.sln
├── src/
│   ├── MallBroadcast.Abstractions/
│   │   ├── Models/                # Program, ScheduleTime, AudioZone
│   │   ├── Enums/                 # PlaybackPriority, ProgramType, RepeatMode
│   │   ├── Events/                # PlaybackStartedEvent, ...
│   │   ├── Interfaces/            # ICoreService, IPlaybackService, ITtsService
│   │   └── Dtos/                  # 跨层传输对象
│   ├── MallBroadcast.Core/
│   │   ├── Scheduling/            # 调度引擎、节假日判断
│   │   ├── Playback/              # 播放服务、优先级
│   │   ├── Audio/                 # NAudio 封装、设备管理
│   │   ├── Licensing/             # 机器码、许可证验证
│   │   ├── Configuration/         # 配置读写、迁移
│   │   ├── Events/                # EventBus 实现
│   │   ├── Plugins/               # PluginLoader
│   │   ├── Tts/                   # EdgeTtsService
│   │   └── Services/              # CoreService 实现
│   ├── MallBroadcast.App/         # WPF UI
│   ├── MallBroadcast.Service/     # Windows Service
│   ├── MallBroadcast.Api/         # REST API
│   └── MallBroadcast.Plugin.Sample/
└── tests/
    ├── MallBroadcast.Core.Tests/
    ├── MallBroadcast.Audio.Tests/
    └── MallBroadcast.Integration.Tests/

 


十一、小结

本文讲了技术选型和架构设计:

  1. 三条底线:单机运行、访问声卡、常驻后台。

  2. 选型:WPF + NAudio + Edge TTS,不用 Electron、WinForms。

  3. 分层:Abstractions / Core / App / Service / Api / Plugin,依赖方向单向。

  4. 核心:Abstractions 零依赖,插件只引用接口。

  5. 配置:版本迁移、原子写入、防抖。

  6. 日志:NLog + ILogger 抽象。

最核心的决策是 Abstractions 层。它让插件零依赖、让测试可 Mock、让未来换实现不影响上层。虽然重构花了一周,但后期开发效率提升三倍。

下一篇,我们聊多分区与优先级:如何用 20 元的 USB 声卡实现 4 个分区独立播放,如何设计四级优先级,如何实现 Ducking 让找人广播时背景音乐自动压低。

posted on 2026-10-02 11:17  feliya52  阅读(3)  评论(0)    收藏  举报

导航