時刻や天気や音量レベルを表示するデスクトップアプリをWPFで作ってみた

## はじめに

ネットを彷徨っていたら、OpenWeatherMap という気象情報を取得できる API を見つけました。
気温や湿度、気圧が不安定だと体調にも影響があるため、デスクトップに気象情報を表示しておけば何かしらの対策が取れるかもと思い、デスクトップアプリを作成してみました。

また、最近の Windows Update (24H2) の影響か、デスクトップに時刻を表示する既存アプリの動作が不調なため、気象情報だけでなく日付と時刻も表示しています。

さらに、自宅にいるときはほぼ常に音楽を聴いていますが、利用しているサブスク系の音楽配信サービスには音量レベルや周波数スペクトラムを表示する機能がなく視覚的な情報も欲しかったため、それらの表示機能も組み込んでみました。

## 作ったもの

Windows のデスクトップに、日付と時刻、現在の天気情報、音量レベルと周波数スペクトラムを表示するデスクトップアプリです。タスクトレイのアイコンから設定画面を表示して、表示位置などいくつか設定を行えるようにしています。

透明ウィンドウで邪魔にならないようにと作り始めましたが無理でした。マルチディスプレイ環境のサブディスプレイの端っこに置いておくと良い感じかと思います。

![img01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/67eda683-e4a4-45a7-9132-5bbe0c8b1f6a.png)

天気情報は OpenWeatherMap の API を呼び出して現在の情報を毎時0分に取得して更新しています。

音量レベルや周波数スペクトラムは Bass のライブラリを利用して既定のサウンドデバイスを特定し、WASAPIを利用してサウンド出力から音量レベルの取得、FFTバッファから周波数ごとのピーク値を取得して更新しています。

https://github.com/kerobot/WeatherWiser/

## 参考情報

FFT を含むオーディオ関連の知識が足りないため大変参考になりました。ありがとうございます。

https://qiita.com/osamus/items/5093cefe20e8f817bbef

https://qiita.com/BlueEventHorizon/items/ae0ca036c1408e20bf7a

https://qiita.com/panda11/items/e28ae434c0dd64a2dbb7

https://qiita.com/TumoiYorozu/items/5855d75a47ef2c7e62c8

## 開発環境

* Windows 11 24H2
* Visual Studio 2022 17.12.4
* .NET 8
* WPF (Prism)

## アプリの全体像

最初からある程度動きのあるデスクトップアプリを作ろうと考えていたため、UIフレームワークとして WPF（Windows Presentation Foundation）を選択しました。

Prism を利用した MVVM パターンの実装ではじめたのですが、サウンド関連の実装にたどり着いたころには、ビューのコードビハインドにゴリゴリ実装していました。いつかリファクタリングしたいです。

サウンドデバイスはアプリ起動時の既定のデバイス固定としているため、アプリ実行中のデバイスの切り替えには対応していません。切り替え時のデバイスの再初期化などを組み込む必要があります。

アプリを実行すると、タスクトレイにアイコンが表示され、デスクトップに日時や天気や音量メーターなどが表示されます。タスクトレイから終了できます。

## タスクトレイへの通知アイコンの表示

### 起動時の通知アイコンの表示

アプリ起動時に App.xaml.cs で通知アイコンをタスクトレイに表示しています。
通知アイコンを右クリックした際に、設定と終了を選択できるようにしています。

``` csharp
protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);
    InitializeNotifyIcon();
}

private void InitializeNotifyIcon()
{
    _notifyIcon = new NotifyIcon
    {
        Icon = new System.Drawing.Icon("Resources/WeatherWiser.ico"),
        Visible = true,
        Text = "Weather Wiser"
    };

    var contextMenu = new ContextMenuStrip();
    contextMenu.Items.Add("Settings", null, OnSettingsClicked);
    contextMenu.Items.Add("Exit", null, OnExitClicked);
    _notifyIcon.ContextMenuStrip = contextMenu;
}

private void OnSettingsClicked(object sender, EventArgs e)
{
    var settingsWindow = Container.Resolve<SettingsWindow>();
    settingsWindow.ShowDialog();
}

private void OnExitClicked(object sender, EventArgs e)
{
    Shutdown();
}

protected override void OnExit(ExitEventArgs e)
{
    _notifyIcon.Dispose();
    base.OnExit(e);
}
```

## 透明なウィンドウの作成

### XAML 側の Window の属性による透過設定

まずは XAML 側で Window の属性を設定してウィンドウを透過させます。
タスクバーにも表示されないようにしておきます。

``` xml
<Window x:Class="WeatherWiser.Views.MainWindow"
        ...
        WindowStyle="None"        // 境界線なし
        AllowsTransparency="True" // Background 属性の透明度を有効化
        Background="Transparent"  // 背景を透明化
        ShowInTaskbar="False"     // タスクバーに表示しない
        Width="600"
        Height="600">
```

### C# 側の WINAPI による透過設定

ウィンドウの背後にあるものをクリックできるように WINAPI を利用してレイヤードウィンドウとします。
また、ツールウィンドウとすることで Alt+Tab のウィンドウ切り替えの候補にも表示されないようにしておきます。

```csharp
// ウィンドウハンドルから拡張ウィンドウスタイルを取得
var hwnd = new WindowInteropHelper(this).Handle;
int extendedStyle = GetWindowLong(hwnd, GWL_EXSTYLE);
...

// 拡張ウィンドウスタイルに対して WS_EX_LAYERED と WS_EX_TOOLWINDOW を追加
int windowResult = SetWindowLong(hwnd, GWL_EXSTYLE, extendedStyle | WS_EX_LAYERED | WS_EX_TOOLWINDOW);
...

private const int GWL_EXSTYLE = -20;             // 拡張ウィンドウスタイル
private const int WS_EX_LAYERED = 0x00080000;    // レイヤードウィンドウ
private const int WS_EX_TOOLWINDOW = 0x00000080; // ツールウィンドウ

[DllImport("user32.dll")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);

[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
```

### C# 側の透過率変更処理

ウィンドウにマウスカーソルが乗った時に表示している文字も含めて透過率を変更します。
CompositionTarget.Rendering イベントでフレームが更新されるたびにマウスカーソルとウィンドウの位置から判定します。（CompositionTarget.Rendering による更新はフレームレートが安定しないことに注意）
ただし、毎回の判定と透明度の更新は負荷がかかるため、デバウンス処理により頻度をいくらか抑制しています。

``` csharp
private DateTime _lastRenderTime = DateTime.MinValue;
private readonly TimeSpan _renderInterval = TimeSpan.FromMilliseconds(100);

public MainWindow()
{
    ...
    CompositionTarget.Rendering += OnRendering;
    ...
}

private void OnRendering(object sender, EventArgs e)
{
    // デバウンス処理
    if (DateTime.Now - _lastRenderTime < _renderInterval)
    {
        return;
    }
    _lastRenderTime = DateTime.Now;

    // ウィンドウ表示判定
    if (!IsWindowVisible())
    {
        return;
    }

    // カーソルの位置に応じてウィンドウの透過率を変更
    var cursorPosition = System.Windows.Forms.Cursor.Position;
    var windowPosition = PointToScreen(new Point(0, 0));
    double newOpacity = IsCursorInsideWindow(cursorPosition, windowPosition) ? 0.1 : 1.0;
    SetWindowOpacity(newOpacity);
}

private bool IsWindowVisible()
{
    // ウィンドウが表示されているかを判定
    return this.IsVisible && this.WindowState != WindowState.Minimized;
}

private bool IsCursorInsideWindow(System.Drawing.Point cursorPosition, Point windowPosition)
{
    // カーソル位置とウィンドウ位置およびサイズの比較
    return cursorPosition.X >= windowPosition.X && 
           cursorPosition.X <= windowPosition.X + this.ActualWidth &&
           cursorPosition.Y >= windowPosition.Y && 
           cursorPosition.Y <= windowPosition.Y + this.ActualHeight;
}

private void SetWindowOpacity(double opacity)
{
    // 透過率が変更される場合のみ処理を行う
    if (this.Opacity != opacity)
    {
        this.Opacity = opacity;
    }
}
```

## 天気情報の表示

### OpenWeatherMap を利用した天気情報の取得

OpenWeatherMapは、Webやモバイルアプリケーションの開発者に、現在の天候や予測履歴を含む各種気象データの無料 API を提供するオンラインサービスです。

https://openweathermap.org/current

日付時刻の表示はタイマーで更新しているだけなので割愛しますが、天気情報の表示は OpenWeatherMap の API を毎時0分に呼び出して現在の天気情報を取得しています。

無料アカウントを登録した後に発行されたAPIキーを環境変数に登録して初期化しています。また、取得する地域は App.config で指定しています。（例：Tokyo）

``` csharp
using Newtonsoft.Json.Linq;
using System;
using System.Net.Http;
using System.Threading.Tasks;
using WeatherWiser.Models;

namespace WeatherWiser.Services
{
    public class WeatherService
    {
        private readonly string apiKey;

        public WeatherService()
        {
            // APIキーを環境変数から取得
            apiKey = Environment.GetEnvironmentVariable("WEATHER_API_KEY");
            if (string.IsNullOrEmpty(apiKey))
            {
                throw new InvalidOperationException("API key is not configured.");
            }
        }

        public async Task<WeatherInfo> GetWeatherAsync(string city)
        {
            // OpenWeather の API を呼び出して JSON 形式の天気情報を取得
            using HttpClient client = new();
            string url = $"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={apiKey}&units=metric&lang=en";
            HttpResponseMessage response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            string responseBody = await response.Content.ReadAsStringAsync();
            JObject weatherData = JObject.Parse(responseBody);

            // 降雨量と降雪量をどちらも降雨量として簡略化
            double precipitationProbability = 0;
            if (weatherData["rain"] != null && weatherData["rain"]["1h"] != null)
            {
                precipitationProbability = (double)weatherData["rain"]["1h"];
            }
            else if (weatherData["snow"] != null && weatherData["snow"]["1h"] != null)
            {
                precipitationProbability = (double)weatherData["snow"]["1h"];
            }

            // WeatherInfo クラスに天気情報を格納
            return new WeatherInfo
            {
                Main = weatherData["weather"][0]["main"].ToString(),
                Description = weatherData["weather"][0]["description"].ToString(),
                Temperature = (int)Math.Round((double)weatherData["main"]["temp"]),
                FeelsLike = (int)Math.Round((double)weatherData["main"]["feels_like"]),
                Humidity = (int)weatherData["main"]["humidity"],
                City = weatherData["name"].ToString(),
                PrecipitationProbability = precipitationProbability,
                WindSpeed = (double)weatherData["wind"]["speed"],
                Pressure = (int)weatherData["main"]["pressure"],
                IconId = weatherData["weather"][0]["icon"].ToString(),
                WindDirection = (int)weatherData["wind"]["deg"]
            };
        }
    }
}
```

## 音量レベルと周波数スペクトラムの表示

### BASS を利用したサウンドデバイスの特定と初期化

BASS は、複数のプラットフォームのソフトウェアで使用するためのオーディオライブラリで、サンプリングやストリーム制御、録音などの機能を提供します。非商用であれば無料で利用できます。

https://www.un4seen.com/

bass.dll だけではなく、WASAPI 入出力をサポートする basswasapi.dll も利用していますが、.NET からはそのまま利用できないため、.NET ラッパーである Bass.Net.dll を介して利用しています。

bass.dll と basswasapi.dll を別途ダウンロードして実行ファイルと同じ場所に配置しています。

```csharp
private void InitBass()
{
    // デバイス情報に Unicode 文字セットを使用する
    Bass.BASS_SetConfig(BASSConfig.BASS_CONFIG_UNICODE, UNICODE);
    // 既定のデバイスを特定
    int deviceCount = BassWasapi.BASS_WASAPI_GetDeviceCount();
    BASS_WASAPI_DEVICEINFO defaultDevice = null;
    for (int i = 0; i < deviceCount; i++)
    {
        var device = BassWasapi.BASS_WASAPI_GetDeviceInfo(i);
        if (device != null)
        {
            // 既定のサウンドデバイスと同名でループバックに対応したデバイスを選択
            if ((device.IsDefault && device.IsEnabled && device.IsLoopback) ||
                (defaultDevice != null && device.IsLoopback))
            {
                Debug.WriteLine($"Device {i}: {device.name}");
                defaultDevice = device;
                _devicenumber = i;
                break;
            }
            else if (device.IsDefault && device.IsEnabled)
            {
                Debug.WriteLine($"Device {i}: {device.name}");
                defaultDevice = device;
                _devicenumber = i;
            }
        }
    }

    if (defaultDevice == null)
    {
        MessageBox.Show("音声出力デバイスが見つかりません。", "エラー", 
            MessageBoxButton.OK, MessageBoxImage.Error);
        return;
    }

    // デバイス情報からミックス周波数を取得
    _mixfreq = defaultDevice.mixfreq;
    // ミックス周波数に応じてFFTデータのサンプル数とFFTバッファ倍率を設定
    SetParamFromFreq(_mixfreq);
    // 再生バッファの更新に使用するスレッド数を設定
    Bass.BASS_SetConfig(BASSConfig.BASS_CONFIG_UPDATETHREADS, false);
    // BASS デバイスの初期化
    bool initResult = Bass.BASS_Init(0, defaultDevice.mixfreq, 
            BASSInit.BASS_DEVICE_DEFAULT, IntPtr.Zero);
    if (!initResult)
    {
        var error = Bass.BASS_ErrorGetCode();
        MessageBox.Show($"BASS 音声出力デバイス初期化時エラーコード: {error}", "エラー", 
            MessageBoxButton.OK, MessageBoxImage.Error);
    }
}

private void SetParamFromFreq(int freq)
{
    // デバイスが 48000Hz の場合、音声の 16000Hz は FFT2048 で 2048 * 16000 / 48000 = 682 要素目あたりになる
    // 音声の 16000Hz が可変となるため、事前に FFTサンプル数 / ミックス周波数 を計算して保持しておく

    switch (freq)
    {
        case <= 48000:  // ~48khz
            _DATAFLAG = BASSData.BASS_DATA_FFT2048;  // 2048 サンプル FFT
            _mixfreqMultiplyer = 2048f / freq;
            break;
        case <= 96000:  // ~96khz
            _DATAFLAG = BASSData.BASS_DATA_FFT4096;  // 4096 サンプル FFT
            _mixfreqMultiplyer = 4096f / freq;
            break;
        case <= 192000: // ~192khz
            _DATAFLAG = BASSData.BASS_DATA_FFT8192;  // 8192 サンプル FFT
            _mixfreqMultiplyer = 8192f / freq;
            break;
        default:        // ~
            _DATAFLAG = BASSData.BASS_DATA_FFT16384; // 16384 サンプル FFT
            _mixfreqMultiplyer = 16384f / freq;
            break;
    }
}

private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
    ...

    // WASAPIの初期化(コールバック関数を指定)
    bool initResult = BassWasapi.BASS_WASAPI_Init(_devicenumber, 0, 0, BASSWASAPIInit.BASS_WASAPI_BUFFER, 1f, 0.05f, _process, IntPtr.Zero);
    ...

    // WASAPIの開始
    bool startResult = BassWasapi.BASS_WASAPI_Start();
    ...
}
```

### BASS を利用した音量レベルの表示

音量レベルは 13 個の矩形を Canvas に2段表示することで LR を表現しました。

```csharp
private void InitializeLevelRectangles()
{
    for (int i = 0; i < 2; i++)
    {
        for (int j = 0; j < 13; j++)
        {
            System.Windows.Shapes.Rectangle rect = new()
            {
                Width = 34,
                Height = 10,
                Opacity = 0.8,
                Fill = Brushes.DimGray
            };
            Canvas.SetLeft(rect, j * 40 + 23);
            Canvas.SetTop(rect, 5 + (15 * i));
            LevelCanvas.Children.Add(rect);
            _levelRects[i, j] = rect;
        }
    }
}
```

タイマーを利用して 25 ミリ秒ごとに音量レベルを更新しています。

音量レベルは 0～short.MaxValue/2 の範囲なので、正規化したうえで平方をとることで微細な音量レベルの変化を強調して 13 段階で表示しています。

```csharp
private void UpdateLevelCanvas()
{
    // 音量レベルを取得して下位 16bit を L、上位 16bit を Rとする
    int level = BassWasapi.BASS_WASAPI_GetLevel();
    this._levels[0] = Utils.LowWord32(level);
    this._levels[1] = Utils.HighWord32(level);

    for (int i = 0; i < _levels.Length; i++)
    {
        // 音量レベルの正規化
        int normalizedLevel = NormalizeValue(this._levels[i], short.MaxValue, _levelPeek);
        // 最大音量レベルの更新（パラパラ降ってくる表現のため）
        this._peekLevels[i] = UpdatePeekValue(this._levels[i], this._peekLevels[i], this._levelDecay);
        // 最大音量レベルの正規化
        int normalizedPeekLevel = NormalizeValue(this._peekLevels[i], short.MaxValue, _levelPeek);
        // 音量レベルに応じて矩形の色を変更
        for (int j = 0; j < _levelPeek; j++)
        {
            _levelRects[i, j].Fill = (j + 1 == normalizedPeekLevel) ? 
                j >= 8 ? Brushes.Red : Brushes.Lime :
                j < normalizedLevel ? 
                j >= 8 ? Brushes.Crimson : Brushes.LimeGreen : Brushes.DimGray;
        }
    }
}

private int NormalizeValue(int value, int maxValue, int count)
{
    // 正規化したうえで平方をとることで微細な変化を強調
    return (int)Math.Ceiling(Math.Sqrt((double)value / (double)maxValue) * count);
}

private int UpdatePeekValue(int value, int peekValue, int decay)
{
    // 音量レベルのピーク値を更新
    peekValue -= decay;
    peekValue = Math.Max(Math.Max(peekValue, 0), value);
    return peekValue;
}
```

### BASS を利用した周波数スペクトラムの表示

周波数スペクトラムは周波数ごとに 10 個の矩形を Canvas に16列表示することで表現しました。

```csharp
private void InitializeSpectrumRectangles()
{
    for (int x = 0; x < 16; x++)
    {
        for (int y = 0; y < 10; y++)
        {
            System.Windows.Shapes.Rectangle rect = new()
            {
                Width = 28,
                Height = 6,
                Opacity = 0.8,
                Fill = Brushes.DimGray
            };
            Canvas.SetLeft(rect, x * 34 + 3);
            Canvas.SetTop(rect, 5 + (9 - y) * 10);
            SpectrumCanvas.Children.Add(rect);
            _spectrumRects[x, y] = rect;
        }
    }
}
```

タイマーを利用して 25 ミリ秒ごとに周波数スペクトラムを更新しています。

FFTデータのサンプル数の範囲を 16 分割した対数スケールの範囲で区切り、その範囲内のピーク値を周波数の最大値として表示しています。

参考にさせていただいたコードをもとに自分なりに整理してみました。
FFTバッファのサイズと取得する可聴域の20Hz～20KHzの範囲を考慮して周波数範囲の成分を切り出しています。

![img02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/6dc45a5e-380b-4d22-97ec-3b2694d91213.png)


```csharp
// FFTデータ(2ch)
private readonly float[] _fft = new float[16384 * 2];
// FFTデータ取得フラグ
private BASSData _DATAFLAG;
// スペクトラムデータの周波数倍率
// およそ20Hz～20KHzの対数スケールとするため
// log(20,2)≒4.32～log(20000,2)≒14.29の範囲を参考に14.29-10=4.29としている）
private readonly float _freqShift = (float)Math.Round(Math.Log(20000, 2) - 10, 2); // = 4.29

...

private void UpdateSpectrumCanvas()
{
    // FFTデータの取得
    int ret = BassWasapi.BASS_WASAPI_GetData(_fft, (int)_DATAFLAG);
    if (ret < -1)
    {
        return;
    }

    // 走査するFFTバッファの位置
    int freqPos = 0;
    // 走査する周波数範囲の上限
    int freqValue = 1;
    // スペクトラムデータのクリア
    _spectrumdata.Clear();

    float[] peeks = new float[_numberOfBar];

    // バンドごとのピーク値を取得
    for (int bandX = 0; bandX < _numberOfBar; bandX++)
    {
        peeks[bandX] = 0;

        // Math.Pow(...) で 20hz~20khz の対数スケールの近似値を取得し、ミックス周波数とFFTサンプル数に応じた倍率を掛ける
        freqValue = (int)(Math.Pow(2, (bandX * 10.0 / (_numberOfBar - 1)) + _freqShift) * _mixfreqMultiplyer);
        if (freqValue <= freqPos)
            freqValue = freqPos + 1;

        // ミックス周波数に応じてFFTバッファから取得する周波数範囲の上限を調整
        freqValue = _mixfreq switch
        {
            <= 48000 => Math.Min(freqValue, 2048),
            <= 96000 => Math.Min(freqValue, 4096),
            <= 192000 => Math.Min(freqValue, 8192),
            _ => Math.Min(freqValue, 16384),
        };

        // 周波数範囲の上限までのFFTバッファを走査してピーク値を取得
        for (; freqPos < freqValue; freqPos++)
        {
            peeks[bandX] = Math.Max(peeks[bandX], _fft[1 + freqPos]);
        }

        // ピーク値の平方根を増幅して0～255の範囲の値に変換（*3と-4は調整値）
        int powerY = (int)(Math.Sqrt(peeks[bandX]) * 3 * 255 - 4);
        powerY = Math.Max(Math.Min(powerY, byte.MaxValue), byte.MinValue);
        _spectrumdata.Add((byte)powerY);
    }

    // 周波数スペクトラムの描画
    for (int x = 0; x < _numberOfBar; x++)
    {
        // バンド値の正規化
        int spectrum = NormalizeValue(this._spectrumdata[x], byte.MaxValue, _spectrumHeight);
        // 最大バンド値の更新（パラパラ降ってくる表現のため）
        this._peekSpectrums[x] = UpdatePeekValue(this._spectrumdata[x], this._peekSpectrums[x], this._spectrumDecay);
        // 最大バンド値の正規化
        int peekSpectrum = NormalizeValue(this._peekSpectrums[x], byte.MaxValue, _spectrumHeight);
        // バンド値に応じて矩形の色を変更
        for (int y = 0; y < _spectrumHeight; y++)
        {
            _spectrumRects[x, y].Fill = y + 1 == peekSpectrum ? Brushes.Lime :
                y < spectrum ? Brushes.LimeGreen : Brushes.DimGray;
        }
    }
}
```

## おわりに

オーディオ関連の知識が足りないため FFT バッファから各周波数範囲のピーク値の取得についてはどうにか納得のいく理解ができましたが、コードビハインドにゴリゴリ記述となってしまいました。

とはいえ、予定していた機能はなんとか組み込めたので、今後はオーディオ関連の学習も進めながらリファクタリングしていこうと思います。

他にも参考にしたいくつかのコードのなかに Delphi のコードがあったり、透明ウィンドウを作るのも四半世紀ぶりだったので懐かしく楽しめました。