TadaoYamaokaの開発日記

個人開発しているスマホアプリや将棋AIの開発ネタを中心に書いていきます。

【バイブコーディング】レトロ戦略ゲームを作る その2(画像素材)

前回、レトロ戦略ゲームの基本部分を生成した。

前回は、サイズなどの形式だけ合った素材をコードインタープリタで生成したので、何のユニットかわからなかった。
今回は、画像素材を生成AIで生成し、既存の素材を置き換えることを試した。

ドット絵

画像素材は、16x16のドット絵という仕様になっている。
画像生成AIの出力サイズは、1024x1024などの固定サイズになっているため、直接16x16サイズで生成はできない。
そこで、生成された画像を切り抜いて、画像編集ソフトで縮小することを試した。

しかし、16x16で縮小した後、ゲーム画面では64x64に拡大して表示しているので、クオリティが低くなった。
16x16で、何のユニットかわかるようにするのはうまくいかず、ドット絵は職人芸だと思った。

ゲーム画面では64x64に拡大しているので、はじめから64x64にした方がよいので、画像素材の仕様を64x64に変更した。

シートで出力

ChatGPTでゲーム素材を生成する良い方法を聞いたところ、一つ一つの画像を生成するよりも、シートで出力すると効率が良いとのとこだったので、シートでまとめて出力するようにした。
ChatGPTにそのためのプロンプトを出力してもらった。

Generate a sprite sheet with 16 strategy game units.
pixel art
top-down
64x64 grid

### Units
- Every file below is required and must be `64x64`, grayscale, and transparent around the sprite.

| File | What to draw |
|---|---|
| `infantry.png` | Foot soldier with firearm. Must clearly read as a person, not a vehicle or icon. |
| `engineer.png` | Support infantry. Can include tool / pack / specialized silhouette distinct from regular infantry. |
| `tank_a.png` | Light tank silhouette. Smallest and simplest tank class. |
| `tank_b.png` | Medium tank silhouette. Heavier than `tank_a`. |
| `tank_z.png` | Heavy tank silhouette. Largest land tank class among regular tanks. |
| `turret.png` | Stationary or emplacement-like gun platform. Must not read as a mobile tank. |
| `sp_artillery_a.png` | Self-propelled artillery, lighter class. Long barrel and rear support silhouette recommended. |
| `sp_artillery_b.png` | Heavier self-propelled artillery. Larger body and heavier gun than `sp_artillery_a`. |
| `anti_air_missile.png` | Anti-air missile vehicle. Missile pod / launcher silhouette should be clear. |
| `anti_air_tank.png` | Anti-air gun vehicle. Turret / gun cluster should read as anti-air role. |
| `rocket_launcher.png` | Ground rocket artillery. Multi-tube launcher silhouette recommended. |
| `armored_car.png` | Fast wheeled armored vehicle. Must read as lighter and faster than tanks. |
| `supply_truck.png` | Logistics vehicle. Cargo bed / container / supply silhouette should be visible. |
| `fighter_a.png` | Fighter aircraft, lighter class. Fast and compact air silhouette. |
| `fighter_b.png` | Fighter aircraft, heavier class. Distinct from `fighter_a` in wing/body proportions. |
| `bomber.png` | Bomber aircraft. Heavier body and payload-oriented silhouette. |

## Style Guide

- 2D pixel art
- 64x64 pixels
- top-down strategy game style
- similar readability to Game Boy Wars / Advance Wars
- slightly cartoon but military readable
- strong silhouettes
- minimal shading (2~4 color ramps)
- clean outline
- no perspective tilt
- no text
- no UI frame
- neutral faction color

### Unit Style

- grayscale only
- transparent background
- clear silhouette
- centered in 64x64
- pixel-art scale readable

このプロンプトで出力された画像は以下の通り。
そのまま使えそうなクオリティで出力された。
色は、ゲーム側でサイドカラーに塗るのでグレースケールになっている。

同様の方法で、残りのユニットと地形の画像素材を生成した。
地形素材は、パースが付いたり、3Dになったりしたので、出力を見て禁止事項を加えて生成し直す必要があった。

加工

生成された画像のサイズは、64x64にはなっていないため、切り抜きと、縮小は画像編集ソフトで手作業で行った。
この作業も自動化できるとよいが、現状コードインタープリタでは難しい。

ゲームの素材置き換え

手作業で加工した画像素材を置き換えた結果、以下のようなゲーム画面になった。

ちゃんとユニットが識別できるようになっており、クオリティも十分である。

占領の色表現

建物の画像が、偶然屋根の部分が赤色になっていたため、これをそのまま活かして、占領した際の色として使うことにした。
青側が占領した場合は、赤系を青系に変えて、未占領はグレーにするようにCodexに変更を依頼した。

占領状態が色で表現されるようになった。


まとめ

画像生成AIで、ゲームの画像素材の制作をおこなった。
ChatGPTの提案でユニットを個別ではなくスプライトシートとしてまとめて生成する方法を採用したことで実用的な画像素材を作成できた。
手作業で切り抜き・サイズ調整は必要だったが、十分使えるクオリティのものが生成できた。

次は、マップエディタを実装したい。

【バイブコーディング】レトロ戦略ゲームを作る

ゲームボーイウォーズというターン制戦略ゲームがある。

1991年に発売されたゲームボーイ向けのゲームだが、当時結構遊んでいた。
続編で、ゲームボーイウォーズターボや、ゲームボーイウォーズ2といったシリーズが発売されている。

あらかじめ用意されているマップでCPUと対戦する形だが、マップをすべてクリアすると飽きるのでもっとマップが欲しいと思っていた。
いつか自作して、好きなマップで遊べるようにしようと思っていたが、実際作るのは大変すぎるのであきらめていた。

ここ数か月、GPT-5.3-CodexやGPT-5.4を使っていて、驚異的なコーディング能力を体験したことで、以前に作りたかったソフトもノーコードで作れる気がしているので、試してみることにした。

方針

  • コードは書かない
  • ゲームルールはDeepResearchしたものを入力する
  • ゲームルール以外は生成AIでオリジナルなものを作る(画像やUIを真似ると著作権上問題があるため)
  • Windowsアプリとする

ゲームルール

GeminiのDeepResearchで、調べたゲームルールをマークダウン形式でリポジトリに配置して、Codexが読めるようにした。
内容の正しさは確認していない。

実装方針

まずは、どのような方針で実装するかをCodexに出力させた。

  • ゲーム基盤(ルールを実装)
  • ゲームエンジン(UI状態、シーン管理など)
  • CPU思考
  • コンソールアプリ

から始める作ることを推奨された。

GUIから作らず、コンソールから作るのは実際の開発でも行うので理にかなっている。

生成

準備として、Visual Studioで、C++/WinUI3のブランクソリューションを作成して、空のプロジェクトを用意した。

そこから、以下のプロンプトで生成した。

design/deep-research-report.md にあるゲームボーイウォーズの仕様を元に、クローンゲームを作りたい。
商標を避けるためゲーム名は「Hex Front」とする。

まずは、Core部分と簡単なAIを実装して、コンソールでテストプレイできるまでを実装してください。

### プロジェクト構成

1. HexFront.Core

種類: C++ Static Library
役割: ゲームルール本体

2. HexFront.AI

種類: C++ Static Library
役割: CPU思考と探索
依存: HexFront.Core

3. HexFront.Console

種類: C++ コンソールアプリ
役割: コンソールでの最低限のゲームプレイ
依存: HexFront.Engine, HexFront.AI, HexFront.Core

今後、GUIで本格的なゲームを実装することを考慮して、CoreとAIはコンソールに依存しないようにすること。
C++20のモダンな実装とすること。

コンソールアプリ

一度目の生成で、コンソールで遊べる状態まで出力された。

合法手生成、CPUまで実装されている。

Hex Front console prototype

Commands
  show           show board and summary
  units          list current side units
  actions <id>   list actions for one unit
  builds         list production actions
  all            list all legal actions
  do <index>     execute from last listed actions
  undo           undo last action
  redo           redo last undone action
  history        list executed actions
  replay [index] show replay list or one replay frame
  surrender      concede immediately
  end            end the current turn
  ai             let AI play the current side
  help           show this help
  quit           exit

Day 1  Turn: Red  Funds(R/B): 14000 / 14000

Board
       0   1   2   3   4   5   6   7   8
  0  ... .mt ... ... ... ... ... ... ...
  1    ... R05 .fo ... .cy ... .fo B12 ...
  2  ... R01 .fo ... .rv ... B08 Bcy B10
  3    .rd Rhq R03 .rd .br .rd B09 Bhq .rd
  4  R04 Rcy R02 ... .rv ... Bfa B07 ...
  5    ... R06 .fo ... .cy ... .fo B11 ...
  6  ... ... ... ... ... ... ... .mt ...

Units for Red
#1 Infantry hp=100 str=10 ammo=9 fuel=99 pos=(1,2) ready
#2 Engineer hp=100 str=10 ammo=3 fuel=70 pos=(2,4) ready
#3 Tank B hp=100 str=10 ammo=4 fuel=32 pos=(2,3) ready
#4 Supply hp=100 str=10 ammo=0 fuel=60 pos=(0,4) ready
#5 Transport Heli hp=100 str=10 ammo=8 fuel=60 pos=(1,1) ready
#6 SP Art B hp=100 str=10 ammo=4 fuel=30 pos=(1,5) ready

GUI版の準備

GUI版は、画像素材が必要になるため、いきなり生成を依頼しても画像素材まで作ることは期待できない。
そこで、事前準備として提供が必要なものをリストアップさせて、どのような形式で配置すればよいか出力させた。

地形とユニットの画像素材の形式と一覧が提示されたので、デザイナーに発注するための詳細な指示書を出力させた。
デザイナーに発注するわけではなく、生成AIで出力するためである。
具体的な色の指定やサイズ、ファイル名などの納品形式もすべて記載されている。

画像生成AIで一つずつ画像を生成するのは大変なので、いったんちゃんとした素材を作る前に、とりあえず形式が合った素材さえあれば良いので、いったんその指示書を元にコードインタープリタで素材一式を作成した。
すべてのファイルが指定したファイル名で作成されて、メタ情報が記載されたxmlと合わせたzipファイルが生成できた。

zipを展開して、指示されたアセットフォルダに格納した。

GUI版生成

準備ができたので、GUI版を生成した。

design/deep-research-report.md にあるゲームボーイウォーズの仕様を元に、クローンゲームを作りたい。
商標を避けるためゲーム名は「Hex Front」とする。

ゲームの基礎部分と、テスト用のコンソール版はすでに実装している。

GUI版をHexFrontフォルダにあるC++/WinRT(WinUI3)のプロジェクトに実装してください。

### GUI技術
- SwapChainPanelで描画領域を作る
- 文字の描画にはDirectWriteを使用
- ウィンドウのクライアントサイズを96DPI基準で、1024×768とする
- DPIによらず見た目上同じサイズになるようにする
- DPIに依存しない実装例:
  - DPIスケーリングは SwapChainPanel + DXGI + D2D の座標系を分離して実装する。
  1. レイアウト座標はDIP、SwapChainバッファは物理ピクセルで扱う。
  2. スケール値は SwapChainPanel.CompositionScaleX/Y を正とし、これを唯一の基準にする。
  3. SwapChainのWidth/Heightは round(ActualWidth * CompositionScaleX), round(ActualHeight * CompositionScaleY) で計算する。
  4. ID2D1DeviceContext::SetDpi と D2DターゲットBitmapのDPIは 96 * CompositionScaleX/Y を使う。
  5. IDXGISwapChain2::SetMatrixTransform は inverse scale(1/CompositionScaleX, 1/CompositionScaleY)を設定する。
  6. SizeChanged / CompositionScaleChanged / XamlRoot.Changed の各イベントでサイズ依存リソースを再計算する。
  7. 描画ロジック(線のX/Y計算)はDIPのみを使い、ピクセル値を混在させない。

### 画面一覧
- 初期実装では対局画面を表示して最低限プレイできること
- 今後、タイトル、マップ選択、シナリオ・マップエディットなどが追加される

### 操作仕様
- マウス主体

### ゲーム素材
- Terrain: `HexFront/Assets/Game/Terrain/`
- Units: `HexFront/Assets/Game/Units/`
- UI: `HexFront/Assets/Game/UI/`
- Metadata: `HexFront/Assets/Game/Metadata/`
  - Asset catalog: [assets.json](/d:/src/HexFront/HexFront/Assets/Game/Metadata/assets.json)
  - `assets.json` is the source of truth for filenames, kinds, sizes, origins, and `tintable` flags.
- 1マス16x16で納品されているため、ゲームでは64x64に整数倍拡大して表示する(フィルタは nearest neighbor)

### 基本部分実装済み
- HexFront.Core: ゲームルール
- HexFront.Engine: ゲームエンジン(コンソール/GUI 非依存)
- HexFront.AI: CPU思考と探索
- HexFront.Console: コンソール版(テスト用)

### 仕様
- design/deep-research-report.md: ゲームボーイウォーズ仕様
- design/additional-report.md: ゲームボーイウォーズ仕様(追加)
- design/24x24damage-matrix.md: ダメージ表
- design/gui-art-brief.md: ゲーム素材作成指示書

生成結果

Visual StudioのDeveloper PowerShellからCodexを起動しているので、ビルドも自動で行ってコンパイルエラーも解消してくれる。

生成されたコードを実行したところ、ルール通りに基本的な操作ができるゲームになっていた。


まとめ

生成AI(GPT-5.4)を使い、昔遊んだ『ゲームボーイウォーズ』風のゲームをコードを書かずに作れるか実験した。
DeepResearchで調べたルールを入力するだけで、コードを一切かかず、GUI版で実際に動作するゲームが完成した。
これに、マップエディタを追加すれば、以前作りたかったものが完成しそうである。

ただし、ゲームとして完成度を高めるには、画像素材のクオリティを上げて、UI操作を改善する必要がある。
音楽と効果音に関しては、生成AIで作るには課題がありそうである。

商用レベルまでクオリティを上げるのは難しいと思うが、そこそこの遊べる状態まで作って、ストアで公開するつもりである。

【Androidアプリ】Audio Pitch Tuner

先日、Microsoft Storeでリリースした、Pitch Tunerを、Androidに移植して、Google Playで公開した。

play.google.com

「Pitch Tuner」は使われていたので、「Audio Pitch Tuner」とした。

バイブコーディング

C++/WinUI3からKotlin/Jetpack Composeへの移植を、GPT-5.4で行った。
さすがに、UIは表示が崩れた機能面は1回で移植できた。
UIも数回の指示で修正でき、コードは1行も書いていない。

これくらいの規模のアプリだと、.NET MAUIとか、React Nativeとかクロスプラットフォームフレームワークいらずである。

アプリ開発の生産性は爆上がりなので、ストアにアプリが溢れかえりそうである。

Microsoft Storeでコマンドラインツールを公開する

以前にVectorで公開していた「WAVファイル変換・抽出ツールwavext」を、Microsoft Storeで公開した。

apps.microsoft.com

wavext

mp3やflacなどの圧縮音声ファイルや、動画から、音声をWAVとして抽出するツールである。
サンプリングレート変換やステレオ/モノラル変換、量子化ビット数変換も行える。

以前はDirectShowを使用していたが、DirectShowはMedia Foundationに置き換えられているので、Media Foundationで実装し直した。
GPT-5.4を使って実装した。

コマンドラインツールの公開方法

Microsoft Storeは、コマンドラインツールも配布できるようになっている。

Microsoft Storeからインストールすると、環境変数PATHを通すことなく、コマンドプロンプトやPowerShellからコマンドを実行できるようになる。

Visual Studioで、ソリューションにMSIXパッケージプロジェクトを追加して、コンソールアプリケーションのプロジェクトを参照設定して、Package.appxmanifestに、

      <Extensions>
        <uap3:Extension
          Category="windows.appExecutionAlias"
          Executable="wavext\wavext.exe"
          EntryPoint="Windows.FullTrustApplication">
          <uap3:AppExecutionAlias>
            <desktop:ExecutionAlias Alias="wavext.exe" />
          </uap3:AppExecutionAlias>
        </uap3:Extension>
      </Extensions>

を追加すればよい。

まとめ

Microsoft Storeは、環境変数PATHとか気にする必要がないのでコマンドラインツールの配布にも便利である。
MSIXの設定が面倒そうだと思っていたが、やってみれば全然難しくなかった。
今後はこの方法でコマンドラインツールを公開していこうと思う。

【Windowsアプリ】Vocal Pitch Monitor for Windows

先日改善したチューナーアプリのピッチ解析をベースに、AndroidとiOSでリリースしているVocal Pitch MonitorのWindows版を作成して、Microsoft Storeでリリースした。

apps.microsoft.com

Windows版では、スマホ版の機能に加えて、録音時間を最大10分まで拡張し、mp3などの圧縮音声ファイルの読み込みにも対応した。

先日、Kontakt5とマイク収録データから学習したモデルによりピッチ解析も向上している。

今後、音源分離モデルボーカル抽出した音声を使って、ボーカル音声を増やしてさらに精度を上げるつもりである。

Microsoft Storeの利用者数はGoogle PlayやApp Storeと比べると圧倒的に少なく、大きな利用は期待していない。
週末にGPT-5.3-Codexを用いてさくっと移植できたので、せっかくなので公開することにした。

GPT-5.3-Codexがなければ、Windows版を開発することはなかったと思う。

Python から VST2 インストゥルメント(VSTi)を読み込んで音を鳴らす その3

前回、Kontakt5で生成した音色×音階のデータを使うと精度が下がったことを記載した。

その後、データクリーニングを行ったところ、精度が下がらないことがわかった。

自動データクリーニング

ACFベースのピッチ解析とSwiftF0のどちらも正解のMIDIと異なるデータを除外した。
SwiftF0がオクターブエラーを起こすことあるため、両方で誤るという条件にしている。

また、マイクで収録した音声についても、同じようにデータクリーニングを行った。
しかし、ベースの4弦でオクターブエラーが起きやすくなったため、原因を調べたところ、ACFのピッチ解析とSwiftF0のどちらもオクターブエラーを起こしていた。
オクターブエラーのデータを残すため、SwiftF0の出力ピッチを1/2、1/4した値が、MIDIの周波数に近い場合は、そのデータを残すことにした。
これにより、ベースの4弦でもオクターブエラーを起こさず正しくピッチ推定できるようになった。

マイク収録音の追加

エレキベース、エレキギター、アコースティックギターで、検証を行い、特定の弦でオクターブエラー起きる場合は、録音データを増やすことで、すべての弦でオクターブエラーを起こさずピッチ推定できるようになった。
今後も、データを増やすだけで精度が担保できるようになる。

チューナーアプリに組み込み

Kontakt5のデータとマイク収録データを合わせて、決定木を学習し、チューナーアプリに組み込んだ。

Microsoft Storeで更新版を公開した。

apps.microsoft.com

様々な楽器で安定してピッチ解析できるチューナーアプリになったと思う。

ピッチの誤差

正確なピッチに対して誤差がどれくらいあるか調べた。

正弦波を入力して、誤差(cent)を測定した結果は以下の通り。

C2以下の低音域で、最大4centの誤差がある。
C2以上では、1cent以内に収まっている。

チューナーの表示は、前のフレームと指数移動平均を取って表示しているため、低音域の誤差はある程度相殺されているはずである。

低音域の誤差を減らすには、窓長を長くする必要があるが、時間分解能は下がるため、バランスを取って決めることになる。
現状は、約340ms の窓長としている。

まとめ

前回Kontakt5生成データで精度低下が見られたが、ACFとSwiftF0の両方で誤判定したデータを除外する自動クリーニングにより精度を維持できることを確認した。
さらに、オクターブエラー対策(SwiftF0出力の1/2・1/4補正)と録音データの拡充により、各弦で安定したピッチ推定を実現した。
改善した決定木モデルをチューナーアプリへ組み込み、Microsoft Storeで更新版を公開した。

Python から VST2 インストゥルメント(VSTi)を読み込んで音を鳴らす その2

前回、PythonでVSTiを読み込んで音を鳴らす方法について調べた。

今回は、Kontakt5のライブラリから音色を連続的に切り替えて自動的にWAVファイルを作ることを試す。

Kontakt5の音色切り替え

前回も記載したが、音色は、VSTiのインターフェースで切り替える方法が用意されておらず、UIから手動で切り替える必要がある。
そこで、PyAutoGUIを使って、マウス、キーボード操作を自動化して切り替えることにした。

音色は、*.nkiファイルをファイルパスで開くことができるので、ディレクトリ内の*.nkiを列挙して、その一覧を順番にロードして、再生する。

音色のロードには数秒くらいかかるため、ロードできたかは、ラックに×ボタンが表示されたかを色で判別するようにした。

鳴らすことができる音階

仮想鍵盤に、鳴らすことができる音階が水色で表示されるため、その範囲を鳴らすことにした。
PyAutoGUIで、座標指摘で色を取得すること判断できた。

実装

以下のようなスクリプトを作成した。
sleepなしだと頻繁にKontakt5が異常終了するため、長めのsleepを入れている。

打楽器系やアルペジエイター、コード系、シンセ、パッドは除外している。

from __future__ import annotations

import argparse
import csv
import re
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np
from pitch_analyzer import midi_to_note_name

try:
    import dawdreamer as daw
except ImportError:
    daw = None

try:
    import pyautogui as pag
    import pygetwindow as gw
    import pyperclip
except ImportError:
    pag = None
    gw = None
    pyperclip = None

try:
    import soundfile as sf
except ImportError:
    sf = None

DEFAULT_KONTAKT_DLL = r"C:\Program Files\Native Instruments\VSTPlugins 64 bit\Kontakt 5.dll"
DEFAULT_NKI_ROOT = r"W:\Native Instruments"
DEFAULT_WINDOW_TITLE = "DawDreamer: Kontakt 5"

PURPLE_KEY_COLOR = (170, 163, 218)
BLUE_KEY_COLOR = (129, 199, 218)
DEFAULT_EXCLUDE_KEYWORDS = ["West Africa Library", "Timpani", "Drum", "Cymbals", "Percussion", "Beats", "Noise", "Chords", "Arpeggiator", "Sequencer", "Perc", "Toys", "Pitchmod", "Blub", "FX Collection", "Wah", "(tremolo", "(fortepiano", "(pizzicato", "(sforzando", "(stac", "(legato", "(all"]
INVALID_PATH_CHARS_RE = re.compile(r'[<>:"/\\|?*]')


@dataclass
class EditorAutomationResult:
    success: bool = False
    error_message: str | None = None
    allowed_notes: list[int] | None = None


def sanitize_path_component(name: str) -> str:
    sanitized = INVALID_PATH_CHARS_RE.sub("_", name).strip().rstrip(".")
    return sanitized if sanitized else "unnamed"


def audio_to_sf_layout(audio: np.ndarray) -> np.ndarray:
    arr = np.asarray(audio, dtype=np.float32)
    if arr.ndim == 1:
        if arr.size == 0:
            raise RuntimeError("Rendered audio is empty.")
        return arr
    if arr.ndim != 2:
        raise RuntimeError(f"Unexpected rendered audio shape: {arr.shape}")

    # Normalize to soundfile layout: (samples, channels).
    # DawDreamer commonly returns (channels, samples).
    if arr.shape[0] <= arr.shape[1]:
        data = arr.T
    else:
        data = arr

    if data.shape[0] <= 0 or data.shape[1] <= 0:
        raise RuntimeError(f"Rendered audio is empty: {arr.shape}")

    # Some plugins return many output buses (e.g. 64ch). Save only L/R.
    if data.shape[1] >= 2:
        data = data[:, :2]

    return np.ascontiguousarray(data, dtype=np.float32)


def wait_for_window(title_keyword: str, timeout: float = 30.0, interval: float = 0.2) -> Any | None:
    start = time.time()
    while time.time() - start <= timeout:
        wins = gw.getWindowsWithTitle(title_keyword)
        if wins:
            return wins[0]
        time.sleep(interval)
    return None


def color_matches(target_color: tuple[int, int, int], candidate_color: tuple[int, int, int], tolerance: int) -> bool:
    return (
        abs(int(target_color[0]) - int(candidate_color[0])) <= tolerance
        and abs(int(target_color[1]) - int(candidate_color[1])) <= tolerance
        and abs(int(target_color[2]) - int(candidate_color[2])) <= tolerance
    )


def get_midi_note_color(win: Any, note: int) -> tuple[int, int, int]:
    rel_x = 587 + (note - 24) * 554 / 84
    if note % 12 in (1, 3, 6, 8, 10):
        rel_x -= 4
    rel_y = 843
    return pag.pixel(win.left + int(rel_x), win.top + int(rel_y))


def wait_load_nki(win: Any, timeout: float = 60.0, interval: float = 0.2) -> None:
    start = time.time()
    while time.time() - start <= timeout:
        close_button_color = pag.pixel(win.left + 1138, win.top + 174)
        if int(close_button_color[0]) >= 200:
            return
        time.sleep(interval)
    raise TimeoutError("Timed out while waiting for Kontakt instrument load.")


def collect_allowed_notes(
    win: Any,
    midi_min: int,
    midi_max: int,
    color_tolerance: int,
) -> list[int]:
    allowed_notes: list[int] = []
    for note in range(midi_min, midi_max + 1):
        color = get_midi_note_color(win, note)
        if color_matches(PURPLE_KEY_COLOR, color, color_tolerance) or color_matches(BLUE_KEY_COLOR, color, color_tolerance):
            allowed_notes.append(note)
    return allowed_notes


def automate_load_nki(
    nki_path: Path,
    window_title: str,
    midi_min: int,
    midi_max: int,
    color_tolerance: int,
    result: EditorAutomationResult,
) -> None:
    try:
        pag.FAILSAFE = False
        pag.PAUSE = 0.05

        win = wait_for_window(window_title, timeout=10.0, interval=0.2)
        if win is None:
            win = wait_for_window("Kontakt 5", timeout=10.0, interval=0.2)
        if win is None:
            raise RuntimeError(f"Kontakt window not found: title_keyword={window_title!r}")

        win.activate()
        time.sleep(1)

        pag.click(win.left + 1138, win.top + 174)
        time.sleep(3)
        pag.click(win.left + 850, win.top + 56)
        time.sleep(0.5)
        pag.click(win.left + 909, win.top + 122)

        load_win = wait_for_window("Load Patch", timeout=10.0, interval=0.2)
        if load_win is None:
            raise RuntimeError("Load Patch dialog was not found.")
        load_win.activate()
        time.sleep(0.5)

        pyperclip.copy(str(nki_path))
        pag.hotkey("ctrl", "v")
        pag.press("enter")

        wait_load_nki(win, timeout=90.0, interval=0.2)
        time.sleep(10)
        allowed_notes = collect_allowed_notes(win, midi_min, midi_max, color_tolerance)

        result.allowed_notes = allowed_notes
        result.success = True
    except Exception as exc:
        result.error_message = str(exc)
    finally:
        try:
            w = wait_for_window(window_title, timeout=1.0, interval=0.1)
            if w is None:
                w = wait_for_window("Kontakt 5", timeout=1.0, interval=0.1)
            if w is not None:
                w.close()
        except Exception:
            pass


def load_patch_and_collect_notes(
    kontakt: Any,
    nki_path: Path,
    window_title: str,
    midi_min: int,
    midi_max: int,
    color_tolerance: int,
) -> list[int]:
    result = EditorAutomationResult()
    worker = threading.Thread(
        target=automate_load_nki,
        args=(nki_path, window_title, midi_min, midi_max, color_tolerance, result),
        daemon=True,
    )
    worker.start()
    kontakt.open_editor()
    worker.join()

    if not result.success:
        raise RuntimeError(f"Failed to load NKI by UI automation: {nki_path} ({result.error_message})")
    return result.allowed_notes or []


def enumerate_nki_files(root: Path, exclude_keywords: list[str]) -> list[Path]:
    if not root.exists():
        raise FileNotFoundError(f"NKI root directory not found: {root}")

    lowered_keywords = [k.lower() for k in exclude_keywords]
    nki_paths: list[Path] = []
    for path in root.rglob("*.nki"):
        lower_path = str(path).lower()
        if any(keyword in lower_path for keyword in lowered_keywords):
            continue
        nki_paths.append(path)
    nki_paths.sort()
    return nki_paths


def build_patch_output_dir(output_root: Path, nki_root: Path, nki_path: Path) -> Path:
    relative_no_ext = nki_path.relative_to(nki_root).with_suffix("")
    sanitized_parts = [sanitize_path_component(part) for part in relative_no_ext.parts]
    return output_root.joinpath(*sanitized_parts)


def has_existing_wav(patch_output_dir: Path) -> bool:
    if not patch_output_dir.exists():
        return False
    return any(p.is_file() and p.suffix.lower() == ".wav" for p in patch_output_dir.iterdir())


def merge_patch_csvs(
    output_root: Path,
    nki_root: Path,
    nki_files: list[Path],
    patch_csv_name: str,
    merged_csv_path: Path,
) -> tuple[int, int]:
    merged_csv_path.parent.mkdir(parents=True, exist_ok=True)
    merged_count = 0
    merged_rows = 0

    with merged_csv_path.open("w", newline="", encoding="utf-8") as out_f:
        writer = csv.DictWriter(out_f, fieldnames=["path", "note"])
        writer.writeheader()

        for nki_path in nki_files:
            patch_output_dir = build_patch_output_dir(output_root, nki_root, nki_path)
            patch_csv_path = patch_output_dir / patch_csv_name
            if not patch_csv_path.exists():
                continue

            with patch_csv_path.open("r", newline="", encoding="utf-8") as in_f:
                reader = csv.DictReader(in_f)
                if reader.fieldnames is None:
                    continue
                if "path" not in reader.fieldnames or "note" not in reader.fieldnames:
                    continue

                row_count_this_file = 0
                for row in reader:
                    writer.writerow(
                        {
                            "path": str(row.get("path", "")),
                            "note": str(row.get("note", "")),
                        }
                    )
                    merged_rows += 1
                    row_count_this_file += 1

                if row_count_this_file > 0:
                    merged_count += 1

    return merged_count, merged_rows


def render_and_save_note(
    kontakt: Any,
    engine: Any,
    midi_note: int,
    velocity: int,
    lead_in: float,
    note_duration: float,
    tail_sec: float,
    sample_rate: int,
    output_wav_path: Path,
) -> None:
    kontakt.clear_midi()
    kontakt.add_midi_note(int(midi_note), int(velocity), float(lead_in), float(note_duration))

    render_sec = max(float(lead_in) + float(note_duration) + float(tail_sec), float(lead_in) + float(tail_sec))
    engine.render(render_sec)
    audio = engine.get_audio()

    audio_for_sf = audio_to_sf_layout(np.asarray(audio, dtype=np.float32))
    output_wav_path.parent.mkdir(parents=True, exist_ok=True)
    sf.write(
        str(output_wav_path),
        audio_for_sf,
        int(sample_rate),
        format="WAV",
        subtype="PCM_16",
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Render Kontakt5 .nki patches and save each note as an individual WAV file."
    )
    parser.add_argument("--output-dir", type=Path, required=True, help="Output root directory for rendered wav files.")
    parser.add_argument("--patch-csv-name", type=str, default="notes.csv", help="CSV filename saved in each patch folder.")
    parser.add_argument(
        "--merged-csv",
        type=Path,
        default=None,
        help="Merged CSV path for all patch CSV files. If omitted, <output-dir>/all_notes.csv is used.",
    )
    parser.add_argument("--kontakt-dll", type=Path, default=Path(DEFAULT_KONTAKT_DLL), help="Kontakt 5 VST dll path.")
    parser.add_argument("--nki-root", type=Path, default=Path(DEFAULT_NKI_ROOT), help="Root folder to search *.nki.")
    parser.add_argument("--window-title", type=str, default=DEFAULT_WINDOW_TITLE, help="Kontakt editor window title keyword.")
    parser.add_argument(
        "--exclude-keywords",
        nargs="*",
        default=DEFAULT_EXCLUDE_KEYWORDS,
        help="Exclude .nki paths containing these keywords.",
    )
    parser.add_argument("--sample-rate", type=int, default=48000, help="Render sample rate.")
    parser.add_argument("--buffer-size", type=int, default=256, help="Render buffer size.")
    parser.add_argument("--bpm", type=float, default=120.0, help="Render BPM.")
    parser.add_argument("--velocity", type=int, default=100, help="MIDI note velocity.")
    parser.add_argument("--midi-min", type=int, default=24, help="Minimum MIDI note to use (inclusive).")
    parser.add_argument("--midi-max", type=int, default=108, help="Maximum MIDI note to use (inclusive).")
    parser.add_argument("--note-duration", type=float, default=3.0, help="MIDI note duration per note (sec).")
    parser.add_argument("--lead-in", type=float, default=0.1, help="Render lead-in before note-on (sec).")
    parser.add_argument("--tail-sec", type=float, default=1.0, help="Render tail after note-off (sec).")
    parser.add_argument("--max-files", type=int, default=None, help="Optional max number of .nki files to process.")
    parser.add_argument(
        "--color-tolerance",
        type=int,
        default=12,
        help="RGB per-channel tolerance for purple/blue key color matching.",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()

    if daw is None:
        raise RuntimeError("dawdreamer is not installed.")
    if pag is None or gw is None or pyperclip is None:
        raise RuntimeError("pyautogui, pygetwindow, pyperclip are required for Kontakt UI automation.")
    if sf is None:
        raise RuntimeError("soundfile is not installed.")
    if args.note_duration <= 0.0:
        raise ValueError("--note-duration must be > 0.")
    if args.sample_rate <= 0:
        raise ValueError("--sample-rate must be > 0.")
    if args.buffer_size <= 0:
        raise ValueError("--buffer-size must be > 0.")
    if args.midi_min < 24 or args.midi_max > 108 or args.midi_min > args.midi_max:
        raise ValueError("MIDI note range must be within 24-108 and midi-min <= midi-max.")
    if args.patch_csv_name.strip() == "":
        raise ValueError("--patch-csv-name must not be empty.")
    if "/" in args.patch_csv_name or "\\" in args.patch_csv_name:
        raise ValueError("--patch-csv-name must be a file name, not a path.")

    nki_files = enumerate_nki_files(args.nki_root, args.exclude_keywords)
    if args.max_files is not None:
        nki_files = nki_files[: max(args.max_files, 0)]
    if not nki_files:
        raise RuntimeError("No .nki files found after filtering.")

    args.output_dir.mkdir(parents=True, exist_ok=True)

    engine = daw.RenderEngine(int(args.sample_rate), int(args.buffer_size))
    engine.set_bpm(float(args.bpm))
    kontakt = engine.make_plugin_processor("Kontakt5", str(args.kontakt_dll))

    patches_processed = 0
    patches_skipped_existing = 0
    notes_saved_total = 0
    notes_candidate_total = 0

    for index, nki_path in enumerate(nki_files, start=1):
        patch_output_dir = build_patch_output_dir(args.output_dir, args.nki_root, nki_path)
        patch_csv_path = patch_output_dir / args.patch_csv_name

        if has_existing_wav(patch_output_dir):
            patches_skipped_existing += 1
            print(f"[{index}/{len(nki_files)}] skipped (existing wav): {nki_path} -> {patch_output_dir}")
            continue

        print(f"[{index}/{len(nki_files)}] Loading patch: {nki_path}")

        allowed_notes = load_patch_and_collect_notes(
            kontakt=kontakt,
            nki_path=nki_path,
            window_title=args.window_title,
            midi_min=args.midi_min,
            midi_max=args.midi_max,
            color_tolerance=args.color_tolerance,
        )
        engine.load_graph([(kontakt, [])])
        allowed_notes = [n for n in allowed_notes if args.midi_min <= n <= args.midi_max]
        notes_candidate_total += len(allowed_notes)

        if not allowed_notes:
            print("  skipped: no allowed keys (purple/blue) in MIDI range.")
            continue

        patch_output_dir.mkdir(parents=True, exist_ok=True)
        csv_exists = patch_csv_path.exists()

        total_notes_in_patch = len(allowed_notes)
        notes_saved_patch = 0
        with patch_csv_path.open("a", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=["path", "note"])
            if not csv_exists or patch_csv_path.stat().st_size == 0:
                writer.writeheader()

            for note_index, midi_note in enumerate(allowed_notes, start=1):
                note_wav_name = f"note_{int(midi_note):03d}.wav"
                note_wav_path = patch_output_dir / note_wav_name
                rel_path = note_wav_path.relative_to(args.output_dir).as_posix()
                csv_path = (args.output_dir / rel_path).as_posix()
                note_name = midi_to_note_name(int(midi_note))
                print(f"  note [{note_index}/{total_notes_in_patch}] midi={midi_note}: rendering -> {note_wav_path}")

                try:
                    render_and_save_note(
                        kontakt=kontakt,
                        engine=engine,
                        midi_note=int(midi_note),
                        velocity=int(args.velocity),
                        lead_in=float(args.lead_in),
                        note_duration=float(args.note_duration),
                        tail_sec=float(args.tail_sec),
                        sample_rate=int(args.sample_rate),
                        output_wav_path=note_wav_path,
                    )
                    writer.writerow({"path": csv_path, "note": note_name})
                    f.flush()
                    notes_saved_patch += 1
                    notes_saved_total += 1
                    print(f"    done: {csv_path}, note={note_name}")
                except Exception as exc:
                    writer.writerow({"path": csv_path, "note": ""})
                    f.flush()
                    print(f"    failed: {csv_path}: {exc}")

        patches_processed += 1
        print(f"  saved: {notes_saved_patch} files, csv={patch_csv_path}")

    merged_csv_path = args.merged_csv if args.merged_csv is not None else (args.output_dir / "all_notes.csv")
    merged_count, merged_rows = merge_patch_csvs(
        output_root=args.output_dir,
        nki_root=args.nki_root,
        nki_files=nki_files,
        patch_csv_name=args.patch_csv_name,
        merged_csv_path=merged_csv_path,
    )

    print(f"Output directory: {args.output_dir}")
    print(f"Merged CSV: {merged_csv_path}")
    print(f"Merged patch CSV files: {merged_count}")
    print(f"Merged rows: {merged_rows}")
    print(f"Processed NKI files: {patches_processed}/{len(nki_files)}")
    print(f"Skipped existing patches: {patches_skipped_existing}")
    print(f"Candidate notes: {notes_candidate_total}")
    print(f"Saved note WAV files: {notes_saved_total}")


if __name__ == "__main__":
    main()

実行結果

395音色について、再生可能な音階から、合計19940音階が抽出された。

訓練データ

以前に作成した特徴量抽出とSwiftF0による疑似ラベル作成のスクリプトのグラウンドトルゥースをMIDIの音階して、訓練データを作成した。
212588個のデータが作成できた。

訓練

決定木を訓練すると、深さ12で正解率が85%とあまり高くない。
チューナーに推論処理を組み込んでみたところ、マイク収録したデータで学習したモデルに対して明らかに精度が下がった。

Kontakt5には様々な音色が含まれており、リリースのないアタックのみの音色や、ピッチが揺れる楽器もあるため、MIDIの音階が正しくないデータを含んでいそうである。
データのラベルを精査する必要があるが、データ量が多すぎるため、手動でデータクリーニングするのは難しい。
Kontakt5の自動再生を苦労して作成したが、このデータを活用するのは保留することにする。

まとめ

PyAutoGUIでKontakt5のUI操作を自動化し、*.nkiを順次ロードして有効音階を取得・各音をWAV出力する仕組みを構築した。
395音色・計19,940音階を抽出し、MIDI音階を正解ラベルとして212,588件の訓練データを生成した。
しかし決定木の精度は最大85%に留まり、音色特性によるラベル不整合の可能性が高く、データ活用は一旦保留とした。

マイク収録した音の方では精度向上ができたので、チューナーアプリに反映予定である。