最近看到一種按下鍵盤就會發出鴨叫聲的療癒鍵盤 雖然聽久了其實挺吵的就是了
原本也想買一把來玩,但看了看價格
嗯....好吧 太貴了 可是又覺得挺有趣的
沒錢買我他x就自己土炮做一個
在工程師精神狀態都很ㄎㄧㄤ的狀態下
就跟設計格鬥動作只有一個月但是設計嘲諷動作有設計半年一樣
於是 就誕生這個ㄎㄧㄤ專案
功能很簡單
只要按下鍵盤上的任何按鍵,程式就會從音效資料夾中隨機挑選一段聲音播放
除了貓叫聲,也可以放入鴨叫.狗叫或其他音效,打造自己的療癒鍵盤
GitHub 請自行取用w
https://github.com/q020385791/Meow
以下簡介經由小弟我編輯後國文太爛打掉重練再請ChatGPT根據我的專案整理過一次
我有再看過整理好不QQ 把AI當成加速工具 不香嗎w han蚌 蒸蚌 潮蚌der~
Meow Keyboard 是一個 Windows 桌面小工具
程式啟動後會自動縮小到系統托盤,接著持續監聽使用者的鍵盤輸入
每當偵測到按鍵按下,就會隨機播放 sounds 資料夾中的音效
目前具備以下功能:
主視窗目前維持簡單設計,只提供「隱藏」與「開機時自動啟動」兩個操作。
這個專案的技術組合如下:
| 技術 | 用途 |
|---|---|
| C# | 主要開發語言 |
| .NET 8 | 執行環境 |
| Windows Forms | 桌面程式介面 |
| Win32 API | 建立全域鍵盤 Hook |
| NAudio | 音效解碼與播放 |
| Windows Registry | 設定開機自動啟動 |
| NotifyIcon | 系統托盤操作 |
整個專案沒有使用資料庫或後端服務,所有功能都在使用者的 Windows 電腦上執行。
程式的主要流程可以整理成:
程式啟動
↓
讀取 sounds 資料夾
↓
將所有音效解碼並載入記憶體
↓
初始化 NAudio 播放引擎
↓
建立系統托盤圖示
↓
註冊 Windows 全域鍵盤 Hook
↓
使用者按下任意鍵
↓
隨機取得一個 CachedSound
↓
停止上一段音效
↓
播放新的音效
雖然功能看起來很單純
實際開發時還是遇到了幾個需要處理的問題
例如全域鍵盤監聽.音效延遲.執行緒切換.音訊格式差異與資源釋放一直出問題差點沒把我搞死
以為鍵盤事件綁定做個30分鐘搞定
一般 Windows Forms 的 KeyDown 事件,只能接收到目前視窗內的鍵盤操作。
但這個工具縮小到系統托盤後,使用者可能正在瀏覽器、Visual Studio、Discord 或其他應用程式中打字,因此必須監聽整個 Windows 的鍵盤輸入。
這裡使用 Win32 API 的 Low-Level Keyboard Hook:
private const int WH_KEYBOARD_LL = 13;
[DllImport(
"user32.dll",
CharSet = CharSet.Auto,
SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(
int idHook,
LowLevelKeyboardProc lpfn,
IntPtr hMod,
uint dwThreadId);
程式啟動時註冊 Hook:
_proc = HookCallback;
_hookID = SetHook(_proc);
SetHook 會取得目前執行程式的 Module Handle,再交給 SetWindowsHookEx:
private static IntPtr SetHook(LowLevelKeyboardProc proc)
{
using var currentProcess = Process.GetCurrentProcess();
using var currentModule = currentProcess.MainModule
?? throw new InvalidOperationException(
"找不到目前行程模組。");
var moduleName = currentModule.ModuleName
?? throw new InvalidOperationException(
"找不到目前行程模組名稱。");
return SetWindowsHookEx(
WH_KEYBOARD_LL,
proc,
GetModuleHandle(moduleName),
0);
}
當 Windows 偵測到按鍵按下時,就會呼叫 Hook Callback:
private static IntPtr HookCallback(
int nCode,
IntPtr wParam,
IntPtr lParam)
{
const int WM_KEYDOWN = 0x0100;
//這裡是關鍵 看事件是啥w
if (nCode >= 0 && wParam == (IntPtr)WM_KEYDOWN)
{
_instance?.QueueRandomSound();
}
return CallNextHookEx(
_hookID,
nCode,
wParam,
lParam);
}
這裡沒有讀取使用者實際輸入的文字,也沒有儲存按鍵內容
程式只判斷 "有按鍵被按下" 這個事情 接著觸發音效播放
完成自己的處理後,需要呼叫 CallNextHookEx
讓事件繼續傳遞給其他 Hook 與應用程式 避免影響原本的鍵盤行為
鍵盤 Hook Callback 應該盡可能快速完成
直接在 Callback 中進行檔案讀取.音訊解碼或複雜運算,會造成鍵盤輸入延遲甚至影響其他應用程式
因此 Callback 只負責通知表單:
_instance?.QueueRandomSound();
接著利用 BeginInvoke,把播放工作排回 Windows Forms 的 UI 執行緒:
private void QueueRandomSound()
{
if (IsDisposed || !IsHandleCreated)
{
return;
}
try
{
BeginInvoke((MethodInvoker)PlayRandomSound);
}
catch (InvalidOperationException)
{
// 表單關閉時,Callback 可能仍在執行。
}
}
這裡也特別檢查表單是否已經 Dispose,以及 Handle 是否已建立
因為程式關閉的瞬間,可能剛好還有一個鍵盤事件正在傳遞
如果沒有處理這個生命週期問題,就可能在關閉時發生例外
專案早期的做法,是在每次按鍵時才開啟音效檔案並進行解碼
這個方式雖然容易實作,但實際使用時會出現明顯延遲 反而更躁了
按下按鍵
→ 開啟音效檔案
→ 解碼 MP3
→ 建立播放裝置
→ 開始播放
鍵盤音效需要很快回應
即使只慢個幾十到幾百毫秒,連續打字時也會覺得聲音跟不上手指
後來我將設計改成啟動時預先解碼,按鍵時直接播放記憶體中的資料
首先建立 CachedSound:
internal sealed class CachedSound
{
public CachedSound(string audioFileName)
{
using var audioFileReader =
new AudioFileReader(audioFileName);
WaveFormat = audioFileReader.WaveFormat;
var audioData = new List<float>(
(int)(audioFileReader.Length / 4));
var readBuffer = new float[
WaveFormat.SampleRate *
WaveFormat.Channels];
int samplesRead;
while ((samplesRead = audioFileReader.Read(
readBuffer,
0,
readBuffer.Length)) > 0)
{
audioData.AddRange(
readBuffer.Take(samplesRead));
}
AudioData = audioData.ToArray();
}
public float[] AudioData { get; }
public WaveFormat WaveFormat { get; }
}
程式啟動時,會列舉 sounds 資料夾中的音效檔:
randomSounds = Directory
.EnumerateFiles(soundsFolder)
.Where(filePath =>
SupportedSoundExtensions.Contains(
Path.GetExtension(filePath)))
.Select(filePath =>
new CachedSound(filePath))
.ToArray();
支援的副檔名集中放在 HashSet:
private static readonly HashSet<string>
SupportedSoundExtensions =
new(StringComparer.OrdinalIgnoreCase)
{
".mp3",
".wav",
".aiff"
};
這樣一來,播放階段就不需要重新讀取硬碟或解碼MP3
缺點是會占用較多記憶體,因此這種設計比較適合短音效
如果放入長達數分鐘的音樂檔案,啟動時間與記憶體使用量都會增加
音效播放使用 NAudio。
建立了一個長駐的 AudioPlaybackEngine,在程式啟動時初始化一次:
audioPlaybackEngine =
new AudioPlaybackEngine();
播放引擎內部使用 WaveOutEvent 與 MixingSampleProvider:
internal sealed class AudioPlaybackEngine : IDisposable
{
private readonly WaveOutEvent outputDevice;
private readonly MixingSampleProvider mixer;
public AudioPlaybackEngine(
int sampleRate = 44100,
int channelCount = 2)
{
outputDevice = new WaveOutEvent();
mixer = new MixingSampleProvider(
WaveFormat.CreateIeeeFloatWaveFormat(
sampleRate,
channelCount))
{
ReadFully = true
};
outputDevice.Init(mixer);
outputDevice.Play();
}
}
播放某個快取音效時,只需要建立一個 Sample Provider,並加入 Mixer:
public void PlaySound(CachedSound sound)
{
AddMixerInput(
new CachedSoundSampleProvider(sound));
}
CachedSoundSampleProvider 的工作,是依照 NAudio 要求的長度,將記憶體中的音訊資料複製到輸出 Buffer:
public int Read(
float[] buffer,
int offset,
int count)
{
var availableSamples =
cachedSound.AudioData.Length - position;
var samplesToCopy =
Math.Min(availableSamples, count);
Array.Copy(
cachedSound.AudioData,
position,
buffer,
offset,
samplesToCopy);
position += samplesToCopy;
return (int)samplesToCopy;
}
這樣就能直接播放已經解碼完成的音效資料。
使用者放進 sounds 資料夾的音效,格式不一定完全相同。
例如:
若直接將格式不同的音訊加入同一個 Mixer,NAudio 可能會發生格式不相容的錯誤。
因此加入音效前,會先檢查 Sample Rate:
if (input.WaveFormat.SampleRate
!= mixer.WaveFormat.SampleRate)
{
input = new WdlResamplingSampleProvider(
input,
mixer.WaveFormat.SampleRate);
}
如果來源是單聲道,輸出是雙聲道,就將 Mono 轉成 Stereo:
if (input.WaveFormat.Channels == 1
&& mixer.WaveFormat.Channels == 2)
{
input = new MonoToStereoSampleProvider(input);
}
else if (input.WaveFormat.Channels
!= mixer.WaveFormat.Channels)
{
throw new NotSupportedException(
"音效聲道數不支援。");
}
最後才加入 Mixer:
mixer.AddMixerInput(input);
這段處理讓使用者可以混用不同來源的音效檔
不需要自己先將所有素材轉成完全相同的格式
每次按鍵時,利用 Random.Shared 隨機挑選一個音效:
private void PlayRandomSound()
{
if (randomSounds.Length == 0)
{
return;
}
var soundIndex =
Random.Shared.Next(randomSounds.Length);
PlaySound(randomSounds[soundIndex]);
}
為了避免連續打字時大量音效重疊,每次播放前會先清除 Mixer 中尚未播放完成的音效:
private void PlaySound(CachedSound sound)
{
audioPlaybackEngine?.StopAllSounds();
audioPlaybackEngine?.PlaySound(sound);
}
停止全部音效的方法很單純:
public void StopAllSounds()
{
mixer.RemoveAllMixerInputs();
}
最後的使用體驗會比較接近鍵盤音效:
按下 A:播放 cat1.mp3
按下 B:中止 cat1.mp3,播放 cat4.mp3
按下 C:中止 cat4.mp3,播放 duck1.mp3
若希望多個音效可以重疊,也可以移除 StopAllSounds()。
這類工具不需要一直占用工作列空間,因此啟動後會直接縮小到系統托盤。
托盤功能使用 Windows Forms 的 NotifyIcon:
notifyIcon = new NotifyIcon
{
Icon = new Icon("img/icon.ico"),
ContextMenuStrip = trayMenu,
Text = "喵喵鍵盤",
Visible = true
};
右鍵選單目前提供「結束」功能:
var exitItem =
new ToolStripMenuItem("結束");
exitItem.Click +=
(sender, eventArgs) =>
Application.Exit();
trayMenu.Items.Add(exitItem);
雙擊左鍵則重新開啟主視窗:
notifyIcon.MouseDoubleClick +=
(sender, eventArgs) =>
{
if (eventArgs.Button
== MouseButtons.Left)
{
ShowFromTray();
}
};
程式第一次顯示後,會透過 BeginInvoke 將視窗隱藏:
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
BeginInvoke(
(MethodInvoker)HideToTray);
}
使用者仍然可以從托盤圖示重新開啟設定畫面
開機啟動功能是透過目前使用者的 Registry 完成。
使用的位置為:
HKCU\Software\Microsoft\Windows\CurrentVersion\Run
使用 HKCU 代表設定只會套用在目前登入的 Windows 使用者。
啟用時,將程式執行路徑寫入 Registry:
key.SetValue(
StartupValueName,
QuotePath(Application.ExecutablePath),
RegistryValueKind.String);
停用時刪除該值:
key.DeleteValue(
StartupValueName,
false);
這裡也需要注意路徑中的空白。
例如程式安裝在:
C:\My Tools\Meow Keyboard\Meow.exe
如果沒有在路徑外加上雙引號,Windows 可能會錯誤解析執行檔位置。
因此寫入前會先處理:
private static string QuotePath(string path)
{
return "\"" + path + "\"";
}
全域 Hook、音效輸出裝置與托盤圖示都屬於需要主動管理生命週期的資源。
如果程式關閉時沒有解除 Hook,可能造成資源殘留或例外。
因此在 OnFormClosing 中集中清理:
protected override void OnFormClosing(
FormClosingEventArgs e)
{
_instance = null;
UnhookWindowsHookEx(_hookID);
_hookID = IntPtr.Zero;
audioPlaybackEngine?.Dispose();
audioPlaybackEngine = null;
notifyIcon.Visible = false;
notifyIcon.Dispose();
trayMenu.Dispose();
base.OnFormClosing(e);
}
這也是開發 Windows 常駐程式時很重要的一個環節
功能能正常執行只是第一步
程式結束時是否能完整清理資源也會影響穩定性
目前專案結構如下:
Meow/
├─ img/
│ └─ icon.ico
├─ sounds/
│ ├─ cat1.mp3
│ ├─ cat2.mp3
│ ├─ cat4.mp3
│ └─ duck1.mp3
├─ AudioPlaybackEngine.cs
├─ CachedSound.cs
├─ CachedSoundSampleProvider.cs
├─ Form1.cs
├─ Form1.Designer.cs
├─ Form1.resx
├─ Program.cs
├─ Meow.csproj
└─ Meow.sln
各檔案主要負責的工作:
Form1.cs負責:
AudioPlaybackEngine.cs負責:
CachedSound.cs負責:
CachedSoundSampleProvider.cs負責:
ISampleProvider
這樣拆分後,表單不需要處理太多音訊細節,播放引擎也不需要知道鍵盤事件是怎麼產生的。
首先確認電腦已安裝:
Clone 專案:
git clone https://github.com/q020385791/Meow.git
接著開啟:
Meow.sln
確認 sounds 資料夾中至少有一個支援的音效檔,再建置並執行專案。
程式啟動後會自動縮小到右下角系統托盤。
之後按下任何鍵盤按鍵,就會隨機播放音效。
只要將音效檔放入 sounds 資料夾即可。
目前支援:
.mp3
.wav
.aiff
專案檔已設定將 sounds 內的檔案複製到輸出目錄,因此建置後也能從執行檔旁邊找到音效素材。
建議使用較短的音效,例如:
使用網路上的音效素材時,仍需確認授權範圍。
這個專案規模不大,卻包含不少 Windows 桌面程式的重要概念
Windows Forms 內建的事件只能處理自身視窗,全系統鍵盤監聽需要進一步使用 Windows Hook
Callback 執行時間過長,可能直接影響使用者輸入體驗
耗時工作應該交給其他執行緒或排入 UI 訊息佇列
一般工具晚個 100 毫秒可能不明顯,鍵盤音效慢 100 毫秒就會產生強烈違和感
將音效預先解碼到記憶體後,操作感受改善很多
不同音效可能具有不同 Sample Rate 與 Channel Count,加入同一個 Mixer 前必須先轉換
全域 Hook.托盤圖示與 Audio Device 都需要在關閉時釋放
否則很容易產生難以追蹤的問題 像是卡工作管理員沒跟著關閉之類的
目前版本仍有一些限制:
後續考慮加入以下功能:
在主視窗加入音量 Slider,讓使用者調整整體播放音量。
例如按下:
Ctrl + Alt + M
即可暫停或恢復鍵盤音效。
讓使用者直接從 UI 選擇音效資料夾,不需要手動將檔案複製到程式目錄。
例如:
Enter → 貓叫
Space → 鴨叫
Backspace → 玻璃破裂聲
其他按鍵 → 隨機播放
使用 FileSystemWatcher 監聽 sounds 資料夾,加入或刪除音效後自動重新載入
將程式打包成單一執行檔或安裝程式,讓沒有開發環境的使用者也能直接安裝
Meow Keyboard 的起點很單純
想玩會發出聲音的鍵盤,又不想花錢買
最後卻一路碰到全域鍵盤 Hook.執行緒切換.音訊快取.格式轉換.系統托盤.Registry 與資源管理等問題
這也是我喜歡做小工具的原因
一個看起來有點無聊
甚至有點鬧的想法
只要真的開始實作
通常都能學到比預期更多的東西(雖然回過頭來看還是很ㄎㄧㄤ
現在打程式時,每一次鍵盤輸入都可能出現不同的貓叫聲
工作效率有沒有提升還不確定
但肯定笑容多了不少
專案 GitHub:
https://github.com/q020385791/Meow
歡迎大家試玩.提出 Issue,或加入自己的奇怪音效
Enjoy your meow typing!🐱
突然想到我之前用claude(語言是python)寫的一個惡搞程式...會在tray中,隨機模擬蚊子叫聲...
完全體現我們的精神狀態XDDD