iT邦幫忙

0

[廢工具]貓貓鍵盤,實現鍵盤療癒自由

  • 分享至 

  • xImage
  •  

沒錢買鴨鴨鍵盤,沒事我自己來

最近看到一種按下鍵盤就會發出鴨叫聲的療癒鍵盤 雖然聽久了其實挺吵的就是了

原本也想買一把來玩,但看了看價格
嗯....好吧 太貴了 可是又覺得挺有趣的
沒錢買我他x就自己土炮做一個
在工程師精神狀態都很ㄎㄧㄤ的狀態下
就跟設計格鬥動作只有一個月但是設計嘲諷動作有設計半年一樣
於是 就誕生這個ㄎㄧㄤ專案

功能很簡單
只要按下鍵盤上的任何按鍵,程式就會從音效資料夾中隨機挑選一段聲音播放
除了貓叫聲,也可以放入鴨叫.狗叫或其他音效,打造自己的療癒鍵盤

GitHub 請自行取用w
https://github.com/q020385791/Meow


以下簡介經由小弟我編輯後國文太爛打掉重練再請ChatGPT根據我的專案整理過一次
我有再看過整理好不QQ 把AI當成加速工具 不香嗎w han蚌 蒸蚌 潮蚌der~

專案簡介

Meow Keyboard 是一個 Windows 桌面小工具

程式啟動後會自動縮小到系統托盤,接著持續監聽使用者的鍵盤輸入
每當偵測到按鍵按下,就會隨機播放 sounds 資料夾中的音效

目前具備以下功能:

  • 按下任意鍵播放隨機音效
  • 支援 MP3、WAV 與 AIFF
  • 啟動時預先載入音效,降低播放延遲
  • 新按鍵觸發時中止前一段音效
  • 程式啟動後自動縮小到系統托盤
  • 雙擊托盤圖示可重新顯示主視窗
  • 可設定隨 Windows 開機自動啟動
  • 關閉程式時釋放鍵盤 Hook 與音效資源

主視窗目前維持簡單設計,只提供「隱藏」與「開機時自動啟動」兩個操作。


使用的技術

這個專案的技術組合如下:

技術 用途
C# 主要開發語言
.NET 8 執行環境
Windows Forms 桌面程式介面
Win32 API 建立全域鍵盤 Hook
NAudio 音效解碼與播放
Windows Registry 設定開機自動啟動
NotifyIcon 系統托盤操作

整個專案沒有使用資料庫或後端服務,所有功能都在使用者的 Windows 電腦上執行。


專案運作流程

程式的主要流程可以整理成:

程式啟動
   ↓
讀取 sounds 資料夾
   ↓
將所有音效解碼並載入記憶體
   ↓
初始化 NAudio 播放引擎
   ↓
建立系統托盤圖示
   ↓
註冊 Windows 全域鍵盤 Hook
   ↓
使用者按下任意鍵
   ↓
隨機取得一個 CachedSound
   ↓
停止上一段音效
   ↓
播放新的音效

雖然功能看起來很單純
實際開發時還是遇到了幾個需要處理的問題
例如全域鍵盤監聽.音效延遲.執行緒切換.音訊格式差異與資源釋放一直出問題差點沒把我搞死
以為鍵盤事件綁定做個30分鐘搞定


監聽整個 Windows 的鍵盤輸入

一般 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 裡執行太多工作

鍵盤 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();

播放引擎內部使用 WaveOutEventMixingSampleProvider

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 資料夾的音效,格式不一定完全相同。

例如:

  • 某個音效是 44,100 Hz
  • 某個音效是 48,000 Hz
  • 某個音效是 Mono
  • 某個音效是 Stereo

若直接將格式不同的音訊加入同一個 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);
}

使用者仍然可以從托盤圖示重新開啟設定畫面


設定 Windows 開機自動啟動

開機啟動功能是透過目前使用者的 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

負責:

  • 全域鍵盤 Hook
  • 隨機選擇音效
  • 系統托盤
  • 開機啟動
  • 程式生命週期

AudioPlaybackEngine.cs

負責:

  • 初始化 NAudio
  • 管理 Mixer
  • 音訊重新取樣
  • 聲道轉換
  • 播放與停止音效

CachedSound.cs

負責:

  • 開啟音效檔
  • 解碼音訊
  • 將音訊資料存入記憶體

CachedSoundSampleProvider.cs

負責:

  • 將快取音訊包裝成 NAudio 的 ISampleProvider
  • 依照播放裝置需求輸出音訊 Sample

這樣拆分後,表單不需要處理太多音訊細節,播放引擎也不需要知道鍵盤事件是怎麼產生的。


如何執行專案

首先確認電腦已安裝:

  • Windows 10 或 Windows 11
  • .NET 8 SDK
  • Visual Studio 2022,並安裝 .NET 桌面開發工作負載

Clone 專案:

git clone https://github.com/q020385791/Meow.git

接著開啟:

Meow.sln

確認 sounds 資料夾中至少有一個支援的音效檔,再建置並執行專案。

程式啟動後會自動縮小到右下角系統托盤。

之後按下任何鍵盤按鍵,就會隨機播放音效。


如何加入自己的音效

只要將音效檔放入 sounds 資料夾即可。

目前支援:

.mp3
.wav
.aiff

專案檔已設定將 sounds 內的檔案複製到輸出目錄,因此建置後也能從執行檔旁邊找到音效素材。

建議使用較短的音效,例如:

  • 貓叫聲
  • 鴨叫聲
  • 機械鍵盤聲
  • 遊戲技能音效
  • 動畫角色語音
  • 自己錄製的聲音

使用網路上的音效素材時,仍需確認授權範圍。


開發過程中學到的事情

這個專案規模不大,卻包含不少 Windows 桌面程式的重要概念

1. 全域監聽需要 Win32 API

Windows Forms 內建的事件只能處理自身視窗,全系統鍵盤監聽需要進一步使用 Windows Hook

2. Hook Callback 必須保持輕量

Callback 執行時間過長,可能直接影響使用者輸入體驗
耗時工作應該交給其他執行緒或排入 UI 訊息佇列

3. 即時互動對延遲非常敏感

一般工具晚個 100 毫秒可能不明顯,鍵盤音效慢 100 毫秒就會產生強烈違和感

將音效預先解碼到記憶體後,操作感受改善很多

4. 音訊格式需要統一

不同音效可能具有不同 Sample Rate 與 Channel Count,加入同一個 Mixer 前必須先轉換

5. 常駐程式要重視資源清理

全域 Hook.托盤圖示與 Audio Device 都需要在關閉時釋放
否則很容易產生難以追蹤的問題 像是卡工作管理員沒跟著關閉之類的


目前限制

目前版本仍有一些限制:

  • 音效只能在程式啟動時載入
  • 新增音效後需要重新啟動程式
  • 沒有音量調整功能
  • 沒有暫停或靜音快捷鍵
  • 沒有指定不同按鍵播放不同音效
  • 沒有限制密碼輸入或遊戲時暫停播放
  • 大量或過長音效會增加記憶體使用量
  • 目前僅支援 Windows

未來規劃

後續考慮加入以下功能:

音量控制

在主視窗加入音量 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!🐱


*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
望空
iT邦研究生 2 級 ‧ 2026-07-24 10:34:10

突然想到我之前用claude(語言是python)寫的一個惡搞程式...會在tray中,隨機模擬蚊子叫聲...

完全體現我們的精神狀態XDDD

我要留言

立即登入留言