C# WinForms钢琴模拟器:用MIDI实现发声、录音与回放
2026/9/13 1:46:18 网站建设 项目流程

简介:面向无乐理基础的音乐爱好者与C#初学者的winform钢琴模拟器,包含完整源码与可直接双击运行的exe,无需安装额外环境即可体验模拟钢琴演奏。软件支持持续发音、简谱制作、录音、键鼠弹奏,兼顾娱乐与音乐学习场景。压缩包共455个文件,约49.19MB,其中包含178个wav音源、62个png界面素材、56个cs源码文件及dll、xml、config等配置支撑文件,目录结构完整。已有270人学习下载。通过源码可深入理解C# winform的事件驱动、音频播放与资源管理实现,适合作为入门级音乐软件开发的练手项目,也可直接用于个人娱乐或简易音乐教学。

1. 一个能发声、能录音的 C# WinForms 钢琴模拟器,源码和双击即用的 exe 都给你

第一次拿到这个 EasyPiano 源码包时,我直接双击 exe,才发现它不是那种套 UI 的玩具:白键黑键用键盘就能弹,按住不放声音会一直延续,松开才收尾。真正让我停下来读源码的是它把“持续发音”做成了 MIDI NoteOn/NoteOff,而不是用常见的 wav 文件循环播,这就让整个项目的代码量变得很干净。对没有乐理基础的人,它能当练手工具;对写 C/S 程序的人,它展示了一套完整的事件驱动玩法:键盘事件、控件动态布局、计时器回放、格式化导出。接下来我会拆开这套项目,讲清楚每个模块为什么这么搭,以及你自己复刻时最容易踩的坑。

2. 发声选型与键位映射:为什么用 MIDI 而不是丢 wav 文件

2.1 SoundPlayer、Beep、MIDI 三种方案对比

很多 WinForms 音乐类 demo 会把每个钢琴音保存成一个 wav 文件,按下键时用System.Media.SoundPlayer播放。这样做的问题很明显:一次只能播一个声音,按下和弦时后面的声音会把前面的打断;而且 wav 资源体积很大,一个 2 秒的钢琴音采样至少几百 KB,14 个键还能忍,88 个键就非常臃肿。还有更偷懒的做法是用Console.Beep,那个频率固定、没有音色变化,只能叫提示音,不能叫钢琴。

这套项目用的是 Windows 系统自带的 MIDI 合成器。通过winmm.dll里的midiOutOpenmidiOutShortMsg发送一条 NoteOn 消息,系统就会用 GM 音色库播放对应音高的钢琴声。NoteOn 之后保持按住时声音会持续,直到发送 NoteOff 才停止,天然满足钢琴模拟器需要的“持续发音”。

方案同时发声数持续发音资源体积适用场景
SoundPlayer 播 wav单音,需多实例拼受 wav 长度限制每个音符一个文件小 demo
Console.Beep不支持蜂鸣提示
midiOut 系列 API支持多通道复音NoteOn/NoteOff 自然支持无额外资源本项目

MIDI 方案对新手还有个隐藏好处:它不写音频数据,只传 4 字节的消息,代码里不容易出现“播放卡顿”这种问题,后面所有录音、回放逻辑都能聚焦在事件序列上,而不是去处理字节流。

2.2 电脑键盘映射到钢琴音符

项目把键盘中排的 A、S、D、F、G、H、J、K、L 当作白键,W、E、T、Y、U 当作黑键,这是一个很经典的电脑钢琴布局。以 C4 为中央 C,映射关系如下,后面所有录音、简谱换算都依赖这同一张表。

键盘键简谱MIDI 音符频率(Hz)
A160261.63
W#161277.18
S262293.66
E#263311.13
D364329.63
F465349.23
T#466369.99
G567392.00
Y#568415.30
H669440.00
U#670466.16
J771493.88
K1'72523.25
L2'74587.33

在 C# 里可以用一个Dictionary<Keys, int>来维护这张表。Keys是 WinForms 里键盘枚举类型,这样做的好处是KeyDown事件里直接拿e.KeyCode查字典,不需要写一长串if判断。

Dictionary<Keys, int> keyMap = new Dictionary<Keys, int> { [Keys.A] = 60, [Keys.W] = 61, [Keys.S] = 62, [Keys.E] = 63, [Keys.D] = 64, [Keys.F] = 65, [Keys.T] = 66, [Keys.G] = 67, [Keys.Y] = 68, [Keys.H] = 69, [Keys.U] = 70, [Keys.J] = 71, [Keys.K] = 72, [Keys.L] = 74 };

这段代码把 MIDI 音高作为唯一标识,后面发声、录音、画简谱都以这个数字为准。K 和 L 没有把 73(E5)和 75(F5)列进来,是为了保证单手九度以内的简单弹奏不别扭,实际扩展时只要继续往字典里加Keys.O = 73这类条目就能把音域拉宽。

2.3 MIDI NoteOn/NoteOff 的 P/Invoke 封装

直接裸写 DllImport 不是不行,但放在一个MidiPlayer类里,窗体代码会干净很多。核心只有三个函数:打开设备、发送 NoteOn、发送 NoteOff。

using System.Runtime.InteropServices; public class MidiPlayer : IDisposable { [DllImport("winmm.dll")] private static extern uint midiOutOpen(out IntPtr hmo, uint uDeviceID, IntPtr dwCallback, IntPtr dwInstance, uint dwFlags); [DllImport("winmm.dll")] private static extern uint midiOutShortMsg(IntPtr hmo, uint dwMsg); [DllImport("winmm.dll")] private static extern uint midiOutClose(IntPtr hmo); private IntPtr _handle; public void Open() { uint code = midiOutOpen(out _handle, 0, IntPtr.Zero, IntPtr.Zero, 0); if (code != 0) throw new Exception($"midiOutOpen 失败,错误码: {code}"); } public void NoteOn(int note, int velocity = 100) => midiOutShortMsg(_handle, (uint)(0x90 | (note << 8) | (velocity << 16))); public void NoteOff(int note, int velocity = 0) => midiOutShortMsg(_handle, (uint)(0x80 | (note << 8) | (velocity << 16))); public void Dispose() => midiOutClose(_handle); }

midiOutOpen的第一个out参数会拿到设备句柄,第二个参数 0 代表使用默认 MIDI 输出设备,也就是 Windows 自带的 GS 波表合成器。发送消息时,0x90是通道 1 的 NoteOn 状态码,0x80是 NoteOff。((note << 8) | (velocity << 16))是在拼一个 32 位整数:低字节是状态,中字节是音高,高字节是力度。注意midiOutShortMsg本身是非阻塞的,它提交后立刻返回,所以按下键时不需要丢到后台线程,直接在 UI 事件里调用也不会卡界面。

如果你在别的机器上运行 exe 出现没有声音,优先检查系统音频设备的 MIDI 合成器是否被禁用,尤其是在 Windows Server 或精简版系统上,默认设备可能为空。

3. WinForms 界面与键盘事件:动态琴键布局、KeyDown/KeyUp 与 UI 刷新

3.1 用代码动态生成白键和黑键而不是拖一堆 Button

如果你在设计器里手动摆 14 个按钮,每次调间距都是灾难。这个项目里更合理的做法是在Form_Load时循环生成 Button,用LocationSize控制位置。白键是 C、D、E、F、G、A、B、C 八个音,黑键穿插在对应白键交界处。

int[] whiteKeys = new int[] { 60, 62, 64, 65, 67, 69, 71, 72 }; int[] blackKeys = new int[] { 61, 63, 66, 68, 70 }; int whiteWidth = 60, whiteHeight = 200; int blackWidth = 36, blackHeight = 120; int x = 12; for (int i = 0; i < whiteKeys.Length; i++) { Button btn = new Button { Text = "C", // 实际上用 NoteName(whiteKeys[i]) Tag = whiteKeys[i], Location = new Point(x, 10), Size = new Size(whiteWidth, whiteHeight), FlatStyle = FlatStyle.Flat }; btn.MouseDown += KeyMouseDown; btn.MouseUp += KeyMouseUp; Controls.Add(btn); x += whiteWidth; }

Tag属性是 WinForms 控件自带的一个 object 容器,这里存 MIDI 音符编号,事件处理时直接用((int)sender.Tag)取值,避免再去解析Text。黑键的x不能简单累加,否则会跑偏,它要放在两个白键的共享边界上,常见算法是whiteKeyIndex * whiteWidth - blackWidth / 2,这个偏移量需要反复调,建议把参数提成常量,方便后面用鼠标拖动调整。

控件的定位参数和视觉属性建议像下面这样集中管理,别散落在事件代码里。

参数推荐值说明
Form.KeyPreviewtrue窗体优先接收键盘事件
白键宽/高60 / 200黑键宽高比约为 3/5
黑键宽/高36 / 120需要手工对齐边界
FlatStyleFlat视觉更接近琴键,且好配色
DoubleBufferedtrue降低重绘闪烁

3.2 用 HashSet 抑制 AutoRepeat 并完成按下/抬起配对

键盘按下时 Windows 会连续触发多次KeyDown,第一次是真实按下,后面都是 AutoRepeat。如果不处理,一个键长按会重复发送几十条 NoteOn,除了噪音没有意义。处理办法是用一个HashSet<Keys>记录当前已经按下的键,KeyDown中成功Add才说明这是首次按下。

private readonly HashSet<Keys> _pressed = new HashSet<Keys>(); private void Form_KeyDown(object sender, KeyEventArgs e) { if (!keyMap.TryGetValue(e.KeyCode, out int note)) return; if (!_pressed.Add(e.KeyCode)) // 已经按过,忽略 AutoRepeat return; midi.NoteOn(note); SetKeyVisual(note, true); if (_recording) recorder.NoteDown(note, _watch.ElapsedMilliseconds); e.Handled = true; e.SuppressKeyPress = true; } private void Form_KeyUp(object sender, KeyEventArgs e) { if (!keyMap.TryGetValue(e.KeyCode, out int note)) return; if (!_pressed.Remove(e.KeyCode)) return; midi.NoteOff(note); SetKeyVisual(note, false); if (_recording) recorder.NoteUp(note, _watch.ElapsedMilliseconds); }

_pressed.Add的返回值是关键:已存在时返回 false,直接 return,这样NoteOn只执行一次。e.SuppressKeyPress会让系统把这个键当作已消费,不再触发文本框输入、按钮快捷键等后续逻辑,这在用字母键当琴键时特别重要,否则按 A 会同时输入一个 "a"。

SetKeyVisual通常只做一件事:改按钮BackColor并按下的音符高亮白键或黑键。因为改颜色会触发控件重绘,在高频弹奏时可能会造成界面轻微闪烁,Flash 周期很短,一般不需要特殊优化;如果要支持 88 键全键盘,建议换自绘方案,后面再展开。

3.3 视觉反馈与“卡顿”问题

WinForms 里最常见的卡顿不是 CPU 吃满,而是 UI 线程被长时间占用。这个项目里声音播放走 MIDI 接口,消息提交后立即返回,所以不存在类似“循环数据采集和 UI 刷新卡顿”的问题。真正会让界面卡的是一个高频定时器里做复杂布局计算,或者用Thread.Sleep去做视觉延迟。

按键高亮如果想要“松手后变暗”,不要在KeyUp里直接Thread.Sleep(100)再恢复颜色,那会阻塞 UI 线程。常见做法是启动一个 100ms 的Timer,Tick 里恢复颜色后立刻停掉。这样按键动画不会挡住下一次弹奏。

private void SetKeyVisual(int note, bool pressed) { if (keyControls.TryGetValue(note, out Button btn)) btn.BackColor = pressed ? Color.DeepSkyBlue : Color.White; }

keyControls是另一个字典,在建键时把 MIDI 音符和按钮控件关联起来。如果你发现界面有闪烁,可以在构造函数里给窗体设置DoubleBuffered = true。WinForms 对这个属性没有公开,直接赋值即可:

public Form1() { InitializeComponent(); this.DoubleBuffered = true; }

双缓冲的原理是先把所有绘图画到内存位图,再一次提交到屏幕,避免多次擦白背景造成的闪烁。对于只有几十个 Button 的界面,这个设置带来的提升非常明显。

4. 录音、回放与简谱导出:把敲击变成可保存的数据

4.1 录音数据模型:NoteEvent 比音频字节流更值得存

如果录一段钢琴曲时直接存 wav,最后文件会很大,而且改错一个音要重新录一遍。项目里采用的是事件序列存储:只记录“哪个音从第几毫秒按下,持续了多少毫秒”。这样保存出来的数据是文本或二进制,几百个音符也不过几 KB。

public class NoteEvent { public int Note { get; set; } public long StartMs { get; set; } public long DurationMs { get; set; } public NoteEvent(int note, long startMs) { Note = note; StartMs = startMs; } }

录音器内部维护一个活动音符字典,NoteDown时创建一个对象,NoteUp时从字典取出并回填DurationMs,然后放到完成列表。这里有个细节:同时按下三个键时,三个NoteUp的释放时间可能不一样,所以字典的 key 不能是单一的Keys,而要用音符编号note

Dictionary<int, NoteEvent> _active = new Dictionary<int, NoteEvent>(); public void NoteDown(int note, long timeMs) { _active[note] = new NoteEvent(note, timeMs); } public void NoteUp(int note, long timeMs) { if (_active.TryGetValue(note, out NoteEvent ev)) { ev.DurationMs = timeMs - ev.StartMs; _completed.Add(ev); _active.Remove(note); } }

用同一把Stopwatch提供时间戳,比各自DateTime.Now精确得多。Stopwatch.ElapsedMilliseconds是系统高频计时器,误差在微秒级,不会出现两个事件顺序错位。

4.2 回放引擎:用 Timer 做时间轴事件分发

回放如果写成一个for循环加Thread.Sleep,弹到一半切窗口或点别的按钮,界面会像死掉一样。正确做法是让一个短间隔的Timer驱动时间轴,每次 Tick 检查有哪些事件的开始时间到了,哪些事件的结束时间到了,然后发送对应 MIDI 消息。

当前播放时间执行动作
event.StartMs <= elapsed发送 NoteOn
elapsed >= event.StartMs + event.DurationMs发送 NoteOff

这里刻意把 NoteOn 和 NoteOff 分到两个循环里,是因为必须允许同一时刻既有新音按下,又有旧音释放。如果在一个循环里先看 Start 再看 End,容易出现先 NoteOff 再 NoteOn 的顺序问题。

private int _playIdx; private List<NoteEvent> _playList; private readonly List<NoteEvent> _activePlay = new List<NoteEvent>(); private void PlaybackTimer_Tick(object sender, EventArgs e) { _elapsedMs += PlaybackTimer.Interval; while (_playIdx < _playList.Count && _playList[_playIdx].StartMs <= _elapsedMs) { NoteEvent ev = _playList[_playIdx++]; midi.NoteOn(ev.Note); _activePlay.Add(ev); } for (int i = _activePlay.Count - 1; i >= 0; i--) { NoteEvent ev = _activePlay[i]; if (_elapsedMs >= ev.StartMs + ev.DurationMs) { midi.NoteOff(ev.Note); _activePlay.RemoveAt(i); } } }

PlaybackTimer.Interval建议取 30ms 到 50ms,低于 10ms 会让 UI 线程忙于 Tick,反而影响时间精度。_elapsedMs累加 Interval 而不是读时钟,是为了避免 Timer 漂移导致的重复触发;如果严格要跟录音时间对齐,可以用Stopwatch重读,但那会出现一次 Tick 里再次进入循环的边界问题,对这个小项目没有必要。

4.3 简谱渲染与保存文本

MIDI 音符到简谱的换算不需要太复杂。以 C 大调为例,白键音符 60、62、64、65、67、69、71 对应简谱 1 到 7,音高每高八度就加一个上标点,低八度加下标点,这里用单引号表示高八度,逗号表示低八度。

static readonly int[] JIANPU_NOTES = { 60, 62, 64, 65, 67, 69, 71 }; static string MidiToJianpu(int note) { int octave = 0; while (note >= 72) { note -= 12; octave++; } while (note < 60) { note += 12; octave--; } int index = Array.IndexOf(JIANPU_NOTES, note); if (index < 0) return index == -1 ? "#" : ""; string result = (index + 1).ToString(); if (octave > 0) result += new string('\'', octave); if (octave < 0) result = new string(',', -octave) + result; return result; }

这段代码把黑键统一返回#,实际项目中会用一个临时字典把黑键显示成#1这类形式,原理一样。输出成文本时,把每个NoteEvent的时间点也一并写上,比如60,0,400表示中央 C 在第 0 毫秒按下,持续 400 毫秒。保存成.txt文件后,下次启动可以把文件读回来重新生成NoteEvent列表并回放,这就完成了一个可存档的简谱编辑器。

5. 发布双击即用的 exe、界面美化与三个常见坑

5.1 单文件发布命令

如果源码是 SDK 风格的 csproj,比如 .NET 8 的 WinForms,直接发布成单文件 exe 是最省心的:

dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true

--self-contained true会把 .NET 运行时打进 exe,目标机器不需要装任何环境,双击就能运行。-r win-x64指定当前架构,如果你要兼容 32 位机器,改成win-x86再发一份。输出目录在bin/Release/net8.0-windows/win-x64/publish/,里面那个 exe 就是成品。如果源码还是老的 .NET Framework 项目,直接在 Visual Studio 里右键项目选“发布”,发布模式选“独立”或“单文件”,效果一样,只是没有这条 CLI 命令直观。

5.2 不那么费劲的界面美化和防闪烁

WinForms 的默认灰色按钮很难看,但做个全自绘又不划算。比较实用的做法是:KeyDown 时把正在按的键换成渐变色,KeyUp 后恢复,再给窗体背景设成深色。这样既不碰复杂 GDI+ 代码,视觉上也更像一架钢琴。

如果需要更彻底的美化,可以把白键黑键改成自定义控件,重写OnPaint,白键用白色填充深灰描边,黑键用黑色填充,然后给窗体和面板都开启双缓冲。这个项目源码里控件版本用按钮够用,但如果你想拿来练习“winform界面美化”和“C# winform主题实现”,把锤子放在OnPaint里画矩形会比放几十个按钮更容易调主题。

5.3 你最可能遇到的三个运行问题

第一个问题是按键弹不出声音。先检查KeyPreview是否设置为 true,否则焦点在某个按钮或 TextBox 上时,键盘消息不会传给窗体处理。

第二个问题是按键重复触发。一定要用HashSet<Keys>挡住 AutoRepeat,且KeyUpKeyDown必须成对出现,不要在KeyUp里重新调NoteOn,那会让声音卡在一直响的状态。

第三个问题是录音时间轴错位。录音端不要用DateTime.Now做时间基准,两个事件间隔不到一毫秒时容易分不出先后,统一用同一个StopwatchElapsedMilliseconds。回放时如果发现结束时间总是比实际晚一个节拍,检查是不是Timer.Interval设得太大,30ms 以下都不会有明显感觉,超过 100ms 就会开始拖拍。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询