C# で ChatGPT API： AI で作曲する (Function calling を使用)

# C# で ChatGPT API： AI で作曲する (Function calling を使用)

こんにちは、@studio_meowtoon です。今回は、WSL Ubuntu 22.04 の C# / .NET 環境にて、ChatGPT API での作曲に Function calling を利用する方法を紹介します。
![chat-gpt_with_csharp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/e76ecf1d-a6e7-9f9e-75bf-8c291cf1eb6a.png)

## 実現すること

ローカル環境の Ubuntu で、ChatGPT API を C# から使い、作曲をさせて MIDI ファイルとして出力します。

:::note warn
この記事では、**従来のプログラム**では**難しい**とされる**クリエイティブな作業**である作曲において、**AI がどのように役立つか**を実際に試してみることを目的の一つとしています。
:::

:::note alert
C# には本来[厳格なコーディング規則](https://learn.microsoft.com/ja-jp/dotnet/csharp/fundamentals/coding-style/coding-conventions)がありますが、この記事では可読性のために、一部規則に沿わない表記方法を使用しています。ご注意ください。
:::

## 開発環境

+ Windows 11 Home 22H2 を使用しています。  

:::note info
[WSL](https://learn.microsoft.com/ja-jp/windows/wsl/about) の Ubuntu を操作していきますので macOS の方も参考にして頂けます。  
:::

<details>

WSL (Microsoft Store アプリ版) [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/2aaa6579cb8e4fa1d874)

```sh
> wsl --version
WSL バージョン: 1.0.3.0
カーネル バージョン: 5.15.79.1
WSLg バージョン: 1.0.47
```

Ubuntu [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/2aaa6579cb8e4fa1d874)

```sh
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.1 LTS
Release:        22.04
```

.NET SDK [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/d68031c91462f6808dff)

```sh
$ dotnet --list-sdks
7.0.202 [/usr/share/dotnet/sdk]
$ dotnet --version
7.0.202
```

</details>

## 前回までに出来たこと

https://qiita.com/studio_meowtoon/items/30fbf924e1c8681863ec

:::note info
ChatGPT に作曲をさせて MIDI ファイルとして出力することができました😋 ※非常に粗削りな旋律の断片です…
:::

## 今ある問題点

:::note alert
ChatGPT API から**望んだ回答**を引き出し、それをいちいち**使える形に成型**するのってめんどくさくないですか？🤔
:::

例えば前回の記事では ChatGPT API から以下のような文字列のレスポンスが得られました。

```sh
注：この問題は、ランダム性が含まれているため、異なる回答が得られる場合があります。以下の回答は、一つの例です。

数値群: C

頻度:
60: 6
62: 2
64: 3
65: 4
67: 5
69: 3
70: 4
72: 5

結果:
{65, 67, 64, 60, 67, 70, 69, 70, 60, 69, 67, 72, 67, 60, 72, 67, 69, 60, 65, 70, 65, 72, 70, 64, 72, 67, 60, 65, 70, 67, 69, 67}
```

そしてこの文字列の中から音程のデータに使用する MIDI ノート番号だけプログラムの処理で抜き出したのです。

```sh
65, 67, 64, 60, 67, 70, 69, 70, 60, 69, 67, 72, 67, 60, 72, 67, 69, 60, 65, 70, 65, 72, 70, 64, 72, 67, 60, 65, 70, 67, 69, 67
```

しかし、このような問題を解決する機能が OpenAI より公式に提供されました😋

## 新機能 Function calling

https://openai.com/blog/function-calling-and-other-api-updates

> Developers can now describe functions to gpt-4-0613 and gpt-3.5-turbo-0613, and have the model intelligently choose to output a JSON object containing arguments to call those functions.

開発者は gpt-4-0613 および gpt-3.5-turbo-0613 に関数を記述し、これらの関数を呼び出すための引数を含む JSON オブジェクトの出力をモデルにインテリジェントに選択させることができるようになりました。

:::note info
これは簡単に言えば、ChatGPT にあらかじめプログラムの中で使用する**関数の仕様を教えて**おき、ChatGPT が会話の文脈を判断してその状況で使うべき**関数の名前**と**引数**をレスポンスしてくれる機能です😋
:::

:::note warn
この記事は Function calling の全てを紹介するものではありません。あくまで Function calling を使用した一例としてとらえて頂けたらと思います。
:::

## AI で作曲する手順

### プロジェクトの作成

基本的な内容は以前の記事を参考にして頂けます。

https://qiita.com/studio_meowtoon/items/30fbf924e1c8681863ec

コンソールアプリを作成します。
※ GPTFCMIDIApp がアプリ名です。

```sh
$ cd ~/tmp
$ dotnet new console -o GPTFCMIDIApp -f net7.0
$ cd ~/tmp/GPTFCMIDIApp
$ dotnet run
Hello, World!
```

### ライブラリの追加

:::note alert
前回の記事で紹介したライブラリは Function calling に対応していない為、フォークされた別のライブラリ **[OpenAI-DotNet](https://github.com/RageAgainstThePixel/OpenAI-DotNet)** を使用します。
:::

```sh
$ dotnet add package OpenAI-DotNet
$ dotnet add package Microsoft.Extensions.Configuration
$ dotnet add package Microsoft.Extensions.Configuration.Json
$ dotnet add package Microsoft.Extensions.Configuration.Binder
$ dotnet add package Sanford.Multimedia.Midi
```

パッケージを確認します。

```sh
$ dotnet list package
プロジェクト 'GPTFCMIDIApp' に次のパッケージ参照が含まれています
   [net7.0]:
   最上位レベル パッケージ                                     要求済み    解決済み
   > Microsoft.Extensions.Configuration             7.0.0   7.0.0
   > Microsoft.Extensions.Configuration.Binder      7.0.4   7.0.4
   > Microsoft.Extensions.Configuration.Json        7.0.0   7.0.0
   > OpenAI-DotNet                                  7.0.3   7.0.3
   > Sanford.Multimedia.Midi                        6.6.2   6.6.2
```

:::note info
ここまでの手順で、開発に必要なライブラリをプロジェクトに設定することが出来ました😋
:::

### 関数の仕様作成

今回 ChatGPT に設定する関数の仕様です。

```json:
"Functions": [
  {
    "name": "write_midi_file",
    "description": "MIDI ノート番号を MIDI ファイルとして出力する",
    "parameters": {
      "type": "object",
      "properties": {
        "mode": {
          "type": "string",
          "description": "Lydian, Ionian, Mixolydian, Dorian, Aeolian, Phrygian, Locrian 文字列のいづれかが提供される。"
        },
        "notes": {
          "type": "string",
          "description": "必ず一つの数値群から選択され、最初の数値が 60 ではない、メロディのようにランダムに並んだ MIDI ノート番号の数値が、カンマ区切りで連続32個提供される。"
        }
      },
      "required": [
        "mode",
        "notes"
      ]
    }
  }
]
```

#### 設定の例

|プロパティ|値|内容|
|:--|:--|:--|
|name|write_midi_file|ChatGPT に認識させる関数の名前を記述します。実際の関数の名前と異なっていても特に問題はありません。|
|description|MIDI ノート番号を MIDI ファイルとして出力する|この関数が何の目的でどのように使われるのかを記述します。|
|parameters.properties.mode|Lydian, Ionian, Mixolydian, Dorian, Aeolian, Phrygian, Locrian 文字列のいづれかが提供される。|(※ユーザ定義) mode パラメータの仕様を記述します。|
|parameters.properties.notes|必ず一つの数値群から選択され、最初の数値が 60 ではない、メロディのようにランダムに並んだ MIDI ノート番号の数値が、カンマ区切りで連続32個提供される。|(※ユーザ定義) notes パラメータの仕様を記述します。|
|parameters.required|mode, notes|引数パラメータとして mode、notes どちらも必要であることを記述します。|

:::note info
このような関数の定義を行い、ChatGPT に設定すると、ChatGPT が文脈の中でユーザーのプログラム側がこのタイミングで実行すべき関数の名前と、その引数を作成してレスポンスしてくれます。今回の記事の例では、ChatGPT から**余計な説明など一切排除**して、**質問の答えだけ**を直接**後段のプログラムから使用出来る形式**で取得することを試みています😋
:::

### ChatGPT API に渡すプロンプト

設定ファイルを読み込む AppSettings.cs を作成します。

```sh
$ vim AppSettings.cs
```

ファイルの内容

```csharp:AppSettings.cs
namespace GPTFCMIDIApp {
    public class AppSettings {
        public string? System { get; set; }
        public string? StartupAssistant { get; set; }
        public List<Function>? Functions { get; set; }
    }
    public class Function {
        public string? name { get; set; }
        public string? description { get; set; }
        public Parameters? parameters { get; set; }
    }
    public class Parameters {
        public string? type { get; set; }
        public Dictionary<string, Propertiy>? properties { get; set; }
        public List<string>? required { get; set; }
    }
    public class Propertiy {
        public string? type { get; set; }
        public string? description { get; set; }
    }
}
```

:::note info
この例では **Function calling** に設定する関数の仕様を appsettings.json から読み込む為、マッピングクラスを作成しています。
:::

appsettings.json を作成します。

```sh
$ vim appsettings.json
```

ファイルの内容

```json:appsettings.json
{
    "System": "数値群から数値を選択してメロディとして連続32個出力する。\n\n数値群:\nA: {60,62,64,66,67,69,71,72}\nB: {60,62,64,65,67,69,71,72}\nC: {60,62,64,65,67,69,70,72}\nD: {60,62,63,65,67,69,70,72}\nE: {60,62,63,65,67,68,70,72}\nF: {60,61,63,65,67,68,70,72}\nG: {60,61,63,65,66,68,70,72}\n\n特別な数値:\nA: {60,66}\nB: {65,71}\nC: {64,70}\nD: {63,69}\nE: {62,68}\nF: {61,67}\nG: {60,66}\n\n次の条件で数値を連続32個作成する。\n\n・数値は必ず一つの数値群の中から選択する\n・数値は重複することが可能である\n・数値は連続することが可能である\n・特別な数値は出現する頻度が極端に低い\n・数値の選択頻度はかなりバラツキがある\n・数値は MIDI ノート番号でありメロディである\n・数値は順番ではなくランダムに選択する\n・数値の出現頻度を集計する\n\n選択された数値群は、次の文字列に変換して出力する:\nA: Lydian\nB: Ionian\nC: Mixolydian\nD: Dorian\nE: Aeolian\nF: Phrygian\nG: Locrian\n\n上記の全ての条件を満たし、かつ選ばれた数値が選択された数値群の数値と一致しているかどうか確認してから出力せよ。",
    "StartupAssistant": "数値群 $TYPE を選択する。回答は Function calling を使用する。",
    "Functions": [
      {
        "name": "write_midi_file",
        "description": "MIDI ノート番号を MIDI ファイルとして出力する",
        "parameters": {
          "type": "object",
          "properties": {
            "mode": {
              "type": "string",
              "description": "Lydian, Ionian, Mixolydian, Dorian, Aeolian, Phrygian, Locrian 文字列のいづれかが提供される。"
            },
            "notes": {
              "type": "string",
              "description": "必ず一つの数値群から選択され、最初の数値が 60 ではない、メロディのようにランダムに並んだ MIDI ノート番号の数値が、カンマ区切りで連続32個提供される。"
            }
          },
          "required": [
            "mode",
            "notes"
          ]
        }
      }
    ]
}
```

:::note info
ここまでの手順で、ChatGPT API に入力するプロンプト、関数の仕様を含んだ appsettings.json の内容を AppSettings オブジェクトに読み込む設定ができました😋
:::

### メインプログラムの作成

Program.cs を修正します。

```sh
$ vim Program.cs
```

<details>
<summary>コードの全体を表示する</summary>

```csharp:Program.cs
using System.Text.Json;
using System.Text.Json.Nodes;
using static System.Console;
using static System.ConsoleColor;
using static System.Environment;
using static System.Text.Encodings.Web.JavaScriptEncoder;
using static System.Text.Unicode.UnicodeRanges;

using Microsoft.Extensions.Configuration;
using OpenAI;
using OpenAI.Chat;
using Sanford.Multimedia.Midi;
using static Sanford.Multimedia.Midi.ChannelCommand;
using static Sanford.Multimedia.Midi.MetaType;

using static GPTFCMIDIApp.Converter;

namespace GPTFCMIDIApp {
    class Program {
        static Random _random = new();
        static async Task Main(string[] args) {
            // ChatGPT API から問い合わせの結果を取得
            (string? mode, int[]? notes) = await getAssistant();
            // MIDI ファイルを保存
            writeMIDIFile(mode, notes);
        }
        // ChatGPT API に問い合わせます
        static async Task<(string? mode, int[]? notes)> getAssistant() {
            // AppSettings オブジェクトを取得
            IConfiguration configuration = new ConfigurationBuilder()
                .AddJsonFile(path: "appsettings.json").Build();
            AppSettings? app_settings = configuration.Get<AppSettings>();

            // プロンプトを取得
            string? system = app_settings?.System;
            string? startup_assistant = app_settings?.StartupAssistant;
            int number = _random.Next(minValue: 1, maxValue: 8);
            Alphabet alphabet = (Alphabet) Enum.ToObject(typeof(Alphabet), number);
            string? type = Enum.GetName(typeof(Alphabet), alphabet);
            startup_assistant = startup_assistant?.Replace("$TYPE", type ?? "B");
            string user = $"数値群 {type} を選択する。回答は Function calling を使用する。";

            // 関数の仕様を JSON に再展開する
            List<Function>? setting_functions = app_settings?.Functions;
            Function? function = setting_functions?[0];
            JsonSerializerOptions options = new(){ Encoder = Create(All) };
            string? parameters_json = JsonSerializer.Serialize(function?.parameters, options);
            JsonNode? parameters = JsonNode.Parse(parameters_json);

            // Function オブジェクトを設定
            List<OpenAI.Chat.Function> functions = new() { new OpenAI.Chat.Function(
                name: function?.name,
                description: function?.description,
                parameters: parameters
            )};
#if DEBUG
            ForegroundColor = Blue;
            WriteLine($"SYSTEM:\n{system}\n");
            WriteLine($"STARTUP ASSISTANT:\n{startup_assistant}\n");
            WriteLine($"USER:\n{user}\n");
            ResetColor();
#endif
            // OpenAI の API キーを取得して OpenAIClient オブジェクトを生成
            string? api_key = GetEnvironmentVariable("OPENAI_API_KEY");
            OpenAIClient api = new(api_key);

            // ChatMessage オブジェクトのリストを作成してプロンプトを設定
            List<Message> messages = new();
            messages.Add(new Message(Role.System, system));
            messages.Add(new Message(Role.Assistant, startup_assistant));
            messages.Add(new Message(Role.User, user));

            // ChatGPT API にリクエストして結果を取得
            ChatRequest request = new(
                messages: messages,
                functions: functions,
                functionCall: "auto",
                model: "gpt-3.5-turbo-0613",
                presencePenalty: 0.15d, frequencyPenalty: -0.15d
            );
            ChatResponse response = await api.ChatEndpoint.GetCompletionAsync(request);
#if DEBUG
            ForegroundColor = Green;
            string function_name = response.Choices[0].Message.Function.Name;
            JsonNode arguments = response.Choices[0].Message.Function.Arguments;
            WriteLine($"ARGUMENTS:\n{arguments.ToString()}\n");
            ResetColor();
#endif
            WriteLine($"get note completed.");
            JsonElement? json_element = JsonDocument.Parse(arguments.ToString()).RootElement;
            return (
                json_element?.GetProperty("mode").GetString(), 
                json_element?.GetProperty("notes").GetString()?.Split(',').Select(int.Parse).ToArray()
            );
        }
        // MIDI ファイルを出力します
        static void writeMIDIFile(string? mode, int[]? notes) {
            // シーケンスの作成
            Sequence sequence = new();
            sequence.Format = 1;

            int channel = 0; // MIDI チャンネル番号
            int velocity = 112; // ノートオン時のベロシティ
            int bpm = 120; // テンポ
            int eighth_note_length = 6000 / (bpm * 4); // 8分音符の長さ（ミリ秒）
            string song_name = $"{mode} Song {DateTime.Now.ToString("yyyy-MM-dd_HHmmss")}"; // 曲名
            string copyright = "STUDIO Awesome"; // 著作権

            // ノートの長さを 8分音符に設定
            int duration = eighth_note_length;

            // コンダクタートラックの設定
            Track conductor_track = new();
            conductor_track.Insert(position: 0, new MetaMessage(Tempo, ToByteTempo(bpm)));
            conductor_track.Insert(position: 0, new MetaMessage(TrackName, ToByteArray(song_name)));
            conductor_track.Insert(position: 0, new MetaMessage(Copyright, ToByteArray(copyright)));
            sequence.Add(item: conductor_track);

            // シーケンストラックの設定
            Track sequence_track = new();
            sequence_track.Insert(position: 0, new MetaMessage(TrackName, ToByteArray("Melody")));
            sequence.Add(item: sequence_track);

            // 音階の設定
            int tick = 0; // 時間のトラッキング用変数
            if (notes is null) { return; }
            foreach (int note_number in notes.Take(32)) {
                // ノートオンイベントの作成
                sequence_track.Insert(position: tick, new ChannelMessage(NoteOn, channel, note_number, velocity));
                // 次のノートまでの時間を加算
                tick += duration;
                // ノートオフイベントの作成
                sequence_track.Insert(position: tick, new ChannelMessage(NoteOff, channel, note_number, 0));
            }

            // Bass 音の設定
            tick = 0;
            foreach (int note_number in new int[] { 36, 36, 36, 36 }) { // キーを C に指定
                sequence_track.Insert(position: tick, new ChannelMessage(NoteOn, channel, note_number, velocity));
                tick += (duration * 8); // 全音符
                sequence_track.Insert(position: (tick - 1), new ChannelMessage(NoteOff, channel, note_number, 0));
            }

            // MIDI ファイルの保存
            sequence.Save($"{song_name.Replace(" ", "_")}.mid");
            WriteLine("write midi file completed.");
        }
    }
    // 数値群を指定する列挙型
    enum Alphabet { A = 1, B = 2, C = 3, D = 4, E = 5, F = 6, G = 7 }
}
```

</details>

以下、ポイントを説明していきます。

```csharp:Program.cs ※抜粋
// Function オブジェクトを設定
List<OpenAI.Chat.Function> functions = new() { new OpenAI.Chat.Function(
    name: function?.name,
    description: function?.description,
    parameters: parameters
)};
```

:::note info
OpenAI.Chat.Function オブジェクトのリストを作成しています。こちらはこの例で使用している .NET のライブラリに依存する実装ですが、このように **C# / .NET** でも **Function calling** できます！😆
:::

```csharp:Program.cs ※抜粋
// ChatGPT API にリクエストして結果を取得
ChatRequest request = new(
    messages: messages,
    functions: functions,
    functionCall: "auto",
    model: "gpt-3.5-turbo-0613",
    presencePenalty: 0.15d, frequencyPenalty: -0.15d
);
ChatResponse response = await api.ChatEndpoint.GetCompletionAsync(request);
```

:::note info
ChatRequest オブジェクトを作成する時に関数の仕様を設定しています。関数の仕様も一種の**プロンプト**なので、特に **description** の記述は ChatGPT API からのレスポンスに大きく影響を与えます🤔
:::

```csharp:Program.cs ※抜粋
string function_name = response.Choices[0].Message.Function.Name;
JsonNode arguments = response.Choices[0].Message.Function.Arguments;
JsonElement? json_element = JsonDocument.Parse(arguments.ToString()).RootElement;
return (
    json_element?.GetProperty("mode").GetString(), 
    json_element?.GetProperty("notes").GetString()?.Split(',').Select(int.Parse).ToArray()
);
```

:::note info
このように ChatGPT のレスポンスが既に後段のプログラムから使用出来る形式になっているので、冗長な成形処理などは必要ありません😋
:::

### ChatGPT API から返される Function calling レスポンスの例

```txt
write_midi_file
```
```json
{
  "mode": "Mixolydian",
  "notes": "60,64,69,67,65,67,69,69,65,65,67,69,72,69,70,67,64,62,69,67,65,65,67,69,69,65,65,67,69,70,67,65,64"
}
```

:::note info
ここまでの手順で、ChatGPT API の新しい機能
 Function calling を使用してプログラム処理に適したレスポンスを得ることができました😋
:::

## Function calling どうだった？

+ これまでよりも ChatGPT API の利用価値が高まったと思います。
+ 現実的に考えると、不適切な回答をはじくバリデーション機能を充実させる必要があると思います。
+ また、これまでの普通のプログラムでは、作成したメソッドは100%実装した通りの答えを出力してきました。
+ しかし、用途や実装内容にもよると思いますが、ChatGPT API のレスポンスは開発者の想定外の答えを返すことが非常に多いと感じます。
+ そのようなケースでどのように対応していけば良いのかなど、今後、さらなる学習と検証が必要だと思います😋

## まとめ
ローカル環境の Ubuntu C# / .NET 環境にて、ChatGPT に作曲をさせて MIDI ファイルとして出力する際に、Function calling を利用することができました。

どうでしたか？ Window 11 の WSL Ubuntu に、C# / .NET の開発環境を手軽に構築することができます、ぜひお試しください。今後も .NET / OpenAI の技術トピックなどを紹介していきますので、ぜひお楽しみにしてください。

## 推奨コンテンツ

https://qiita.com/studio_meowtoon/items/30fbf924e1c8681863ec