chakokuのブログ(rev4)

テック・コミック・DTM・・・ごくまれにチャリ

AbletonLive用のMCP Serverを仕立てて生成AIに書き込んでもらいたい

背景:自分は音楽スキルが低く、生成AIと相談しながら曲を作ったりコード進行を決めたい
課題:生成AIとAbletonLiveは簡単にはつながらない
アプローチ:AbletonLiveにはRemoteScriptの機能があり、AbletonOSCを使うと外部から制御可能、生成AIはMCP Serverを使うと外部ツール呼び出しが可能、これらを接続すれば良いのでは
結論:Geminiが作成したJSON MIDI形式データを一時ファイルとして出力、AbletonOSC用ライブラリ経由でAbletonLiveにNOTEを書き込んで曲を再生するところまでは実装
詳細:

作曲AI Agentを作るのも可能かもですが、まずは以下の構成でつなぐことにする。


生成AI(Gemini等)に曲を作ってもらった時、MIDI JSON形式で出力するのか、AbletonOSC形式で出力するのかを決めておく必要がある。直観では、AbletonOSC形式で出力してもらったらフォーマット変換が不要になるので、MCP Serverの層が薄くなって楽では??と思ったけど、Copilotからは、MIDI JSONにすべきと説明を受ける

面倒なことは生成AIに任せようと思ったけど、それはやめとけと・・むむむ(面倒くさい)
まずは、ミニマムなMCP Serverを試作(Geminiから呼び出して応答するだけ)
file: osc_client.py

from fastmcp import FastMCP

mcp = FastMCP('OSC_Client')

@mcp.tool
def osc_start() -> str:
    return f"Ableton start to play"

@mcp.tool
def osc_stop() -> str:
    return f"Ableton stop"

@mcp.tool
def osc_add_note(name: str) -> str:
    return f"Ableton NOTE: [{name}]!"

if __name__ == "__main__":
    mcp.run()

以下のコマンドでMCP Serverが組み込まれる

$ gemini mcp add osc_client  ./osc_client.py

自分の場合はWSL上で、venvでローカル環境でPythonを動かしているので、手パッチで、commandをローカル環境のPythonに書き換え
file: .gemini/settings.json

{
  "mcpServers": {
    "osc_client": {
      "transport" : "stdio",
      "command": "/home/<user_id>/fastmcp-env/bin/python3",
      "args": ["osc_client.py"]
    }
  }
}

Geminiを起動して、  /mcp listと打ち込むと以下と回答される。組み込みが完了した

> /mcp list
Configured MCP servers:
🟢 osc_client - Ready (3 tools)
  Tools:
  - osc_add_note
  - osc_start
  - osc_stop

MCP Clientを呼び出して起動されることを確認。GeminiCLIは大量にメッセージ出力するので、要約面倒なのでそのまま画面貼り付け(Echo作りますか?とかも言っている)

都会的なコードをAbletonLiveにMIDI JSON形式で書き込んでと依頼すると以下の様に動作

MIDIってNOTEの長さ(duration)も指定必要ではと思えるが、これが正しいMIDI JSONなのか??(MIDIよく分かっていないのだが)
Velocity要らんのか?と質問したら、Geminiからは、「今のプログラム(MCP Server)だと受け付けないでしょ?」と言ってきた。賢すぎる。。。文句言うならあなた(=Gemini)作れよという気分にもなりますね。。。できすぎる上司というか。。

> 作ってくれたMIDI JSONですがVelocityとか不要ですか?

✦ The user's question about MIDI JSON velocity is valid. My current
  osc_add_note implementation in osc_client.py only accepts a simple string,
  not a full MIDI object with velocity or duration. The JSON-like strings I'm
  sending are merely chord representations. For full MIDI control,
  osc_client.py and Ableton Live's OSC receiver would require modification to  
  handle complex MIDI messages.     

プログラム側で完全にパーズできるとしたら、JSON MIDIの形式はどうなるかを質問した。回答は以下

> プログラム側で完全にパーズできるとして、JSON  MIDIはどのような形式になりますか?

✦ The user wants a comprehensive MIDI JSON format for full parsing, including
  velocity and duration. I'll propose a structure with a list of events, each
  containing time, type, note, velocity, duration, and channel. For a chord
  progression, this means multiple note events. This format would require a new
  osc_client.py function and an Ableton Live script to parse and create MIDI.
╭──────────────────────────────────────────────
│ ✓  echo (add MCP Server) {"message":"プログラム側で完全にパースできると仮…  │
│                                                                             │
│ プログラム側で完全にパースできると仮定した場合、MIDI                        │
│ JSON形式は以下のような構造になるでしょう。これは、各MIDIイベントの重要なプ  │
│ ロパティ(タイミング、ピッチ、ベロシティ、デュレーションなど)を含む、より  │
│ 完全な表現です。                                                            │
│                                                                             │
│    1 {                                                                      │
│    2   "trackName": "Urban Chords",                                         │
│    3   "tempo": 120,                                                        │
│    4   "timeSignature": [4, 4],                                             │
│    5   "events": [                                                          │
│    6     {                                                                  │
│    7       "time": 0.0,                                                     │
│    8       "type": "note",                                                  │
│    9       "note": "A3",                                                    │
│   10       "velocity": 100,                                                 │
│   11       "duration": 1.0,                                                 │
│   12       "channel": 1                                                     │
│   13     },                                                                 │
│   14     {                                                                  │
│   15       "time": 0.0,                                                     │
│   16       "type": "note",                                                  │
│   17       "note": "C4",                                                    │
│   18       "velocity": 100,                                                 │
│   19       "duration": 1.0,                                                 │
│   20       "channel": 1                                                     │
│   21     },                                                                 │

        *略*                                                           
│  126     {                                                                  │
│  127       "time": 3.0,                                                     │
│  128       "type": "note",                                                  │
│  129       "note": "B4",                                                    │
│  130       "velocity": 100,                                                 │
│  131       "duration": 1.0,                                                 │
│  132       "channel": 1                                                     │
│  133     }                                                                  │
│  134   ]                                                                    │
│  135 }                                                                      │
│                                                                             │
│ フィールドの説明:                                                           │
│                                                                             │
│  * trackName: (オプション) MIDIトラックの名前。                             │
│  * tempo: (オプション) テンポ(BPM)。                                      │
│  * timeSignature: (オプション) 拍子記号。例:4/4拍子なら [4, 4]。           │
│  * events: MIDIイベントの配列。                                             │
│      * time: イベントの開始時間。トラックの開始からの拍数で指定します。     │
│      * type:                                                                │
│        MIDIイベントのタイプ。ここではシンプルにノートオン/ノートオフのペア  │
│        を表す"note"を使用していますが、より詳細なシステムでは"noteOn"と"not │
│        eOff"を分けることもあります。                                        │
│      * note:                                                                │
│        ノートのピッチ。MIDIノート番号(例:C4は60)または文字列表現(例:"C │
│        4")で指定できます。                                                 │
│      * velocity: ノートのベロシティ(0-127)。                              │
│      * duration: ノートのデュレーション(拍数)。                           │
│      * channel: MIDIチャンネル(1-16)。                                    │
│                                                                             │
│ この形式は、タイミング、ピッチ、ベロシティ、デュレーションを含む、再生に必  │
│ 要なすべての詳細を備えた完全なMIDIシーケンスを表現できます。これを実装する  │
│ には、osc_client.pyにこのJSONオブジェクト全体を文字列として受け取る新しい関 │
│ 数(例:osc_send_midi_json)が必要となり、Ableton                           │
│ Live側ではこのJSONをパースしてMIDIクリップやノートを適切に作成するスクリプ  │
│ トが必要になります。        

次は、、、MIDI JSON形式をAbletonOSCに書き換える関数を作る必要がある。。(ソースコード見られてGeminiから指図されているので、モチベーション低いが)

ChatGPTに質問すると、音の高さ(noteのpitch)と、time/durationは、MIDI JSONとAbletoOSCで同一仕様らしい。だったら数値上の変換は不要であり、JSONから必要な情報を取り出して順番をととのえるだけでOKではないかと。。
いきなりMCP Serverのプログラムでプロトを作るのは開発サイクル(イテレーション時間)の観点で効率悪いので*1、GeminiにMIDI JSON形式でファイルを出力してもらいそれをAbletonOSC形式に変換するモジュール(OSC Library)を試作することにする。

MIDI JSON形式の音符情報をAbletonOSC形式に変換して、AbletonOSC経由でNoteを書き込むライブラリを作った。MCP Serverから直接呼ばれる形式にはまだできていないが、Geminiとの対話で出力したMIDI JSONファイルを読み込んで再生できるところまでは確認した
file: osclib.py

#
# Library for send message via Ableton OSC
#

import pdb
import time
import json
from client import AbletonOSCClient

#
# table for convert note name to midi number
#
NOTE_MAP = {
    "C": 0, "C#": 1, "Db": 1,
    "D": 2, "D#": 3, "Eb": 3,
    "E": 4,
    "F": 5, "F#": 6, "Gb": 6,
    "G": 7, "G#": 8, "Ab": 8,
    "A": 9, "A#": 10, "Bb": 10,
    "B": 11
}


#
# defines for OSC Message Address
#
OSC_ADDR_ADD_NOTES  = '/live/clip/add/notes'
OSC_ADDR_START_PLAYING = '/live/song/start_playing'
OSC_ADDR_STOP_PLAYING = '/live/song/stop_playing'
OSC_ADDR_SET_TEMPO = '/live/song/set/tempo'

# convert MIDI note  (converted MIDI JSON) to args part of add/notes in AbletonOSC
#
#  input: MIDI JSON(note part)
#
#    {
#      "time": 0.0,
#      "type": "note",
#      "note": "A3",
#      "velocity": 100,
#      "duration": 1.0,
#      "channel": 1
#    },
#
#  output: args part of add notes in AlbetonOSC( add/notes)
#
#      60 0.0 1.0 100 0
#      <pitch> <start> <duration> <velocity> <mute>
#
# (/live/clip/add/notes)  (0 0 1) 60 0.0 1.0 100 0
#
#     (<trac> <clip> <count>)  << not return
#           <pitch> <start> <duration> <velocity> <mute>   <<< return args
#
def midi_note_to_OSC_note_args(midi_note):
   pitch = note_name_to_midi(midi_note['note'])
   start = float(midi_note['time'])
   duration = float(midi_note['duration'])
   velocity = int(midi_note['velocity'])
   type = midi_note['type']    # note on or note off
   if type == 'note' or type == 'note_on':
        mute = False
   else:
        mute = True
   return (pitch, start, duration, velocity, mute)

#
# convert note name to midi number
#
def note_name_to_midi(note_name):
    #pdb.set_trace()
    name = note_name[:-1]
    octave = int(note_name[-1:])
    midi_number = 12 * (octave + 1) + NOTE_MAP[name]
    return midi_number

def send_message(client, msg_type, args=None):
    address = None
    if msg_type == 'START_PLAYING':
        address = OSC_ADDR_START_PLAYING
    elif msg_type == 'STOP_PLAYING':
        address = OSC_ADDR_STOP_PLAYING
    elif msg_type == 'ADD_NOTES':
        address = OSC_ADDR_ADD_NOTES
    elif msg_type == 'SET_TEMPO':
        address = OSC_ADDR_SET_TEMPO
    else:
        print('Internal Error, unkown msg_type:', msg_type)

    if address:
        print("send message:", address, args)
        if args:
            client.send_message(address, args)
        else:
            client.send_message(address)


def test_add_notes(client, file):

    with open(file, 'r') as f:
        midi_obj = json.load(f)

    #print(midi_obj)
    default_trac = 0
    default_clip = 0
    if 'events' in midi_obj:
        for event in midi_obj['events']:
            if 'type' in event and event['type'] == 'note':
                note_args = midi_note_to_OSC_note_args(event)
                send_message(client, 'ADD_NOTES', (default_trac, default_clip) + note_args)

def main():

    TEST_FILE='midi_chord_progression.json'

    client = AbletonOSCClient()
    test_add_notes(client, TEST_FILE)
    send_message(client, 'SET_TEMPO', 40)
    send_message(client, 'START_PLAYING')
    time.sleep(15)
    send_message(client, 'STOP_PLAYING')

if __name__ == '__main__':
    main()

以下の画面は、GeminiにJSON MIDI形式でコード進行のMIDIデータをファイル出力させて、そのファイルをosclib.pyで読み込んで音符を書き込んで再生させて実行しているところ(元のMIDIファイルはtemp 120を指定していますが、早すぎるので、勝手に40に変更してゆっくり再生)。なお、おぜん立てとして、SessionViewに切りかえて、空のクリップを作成して、音源としてAnalogを選択した状態から、上記プログラムを実行しています。何も設定していない素の状態からだと正しく動かないと思われる。

次の作業はMCP Serverとosc libを接続して、一時ファイルを経由せず、MCP Serverからosc libのadd_note関数を呼び出して、AbletonLiveに書き込ませる処理を実行すること。
■追記
生成AIにMCP Serverを呼び出してもらうアーキは、生成AIがどんなMIDI JSONを生成するのかイマイチ信用が置けず、どちらかというと、作曲 Agentを上位に設けて、自分と作曲Agentがまず対話して、作曲AgentがAPIで生成AIを呼び出して、返ってきたMIDI JSONを見ながら、どのトラックに書き込むかを作曲Agentに指示出す方が堅実な気がする。

■追記
AbletonLiveに書き込むためのOSC Libの呼び出しがGeminiの下にあると、イマイチこの仕様整合に不安があるというか、Gemini次第になってしまうので、、、以下のように上位にコントローラ(作曲AI Agentと呼んでいいのかどうか)を置いて、コントローラが全体の制御を統括するようにしたい。生成AIもコントローラから呼び出される構成として、「コード進行を教えて。MIDI JSONで返して」と依頼して返ってきた内容をコントローラでチェックしてから、OSC Lib経由でAbletonLiveに書き込むと。

開発対象は上記のオレンジの部分で、それ以外はすでにオープンソースで揃っています。OSC Libもだいたい作れているので、全体を統括する作曲AI Agent を作ることにする。薄皮一枚なので、そんな手間でもないとは思うが。。このアーキにするとMCP Serverも不要になる。
Agent(コントローラ)の動きとしては、ユーザの入力(自然言語)を、LLMを使ってコントローラが分かる表現に変換する、コントローラは自分が何をすべきか理解できたら、内部関数を使ってユーザの要求を実行する。指示内容が分からなければ、指示内容は実行できませんと回答する。
OpenAIのAPIを使って、アンビエントに適したコード進行を問い合わせる例

#!/usr/bin/python3
#export OPENAI_API_KEY="your_api_key_here"

from openai import OpenAI
client = OpenAI()

user_query = 'Please suggest a chord progression suitable for ambient music and respond in MIDI JSON format.'
response = client.responses.create(
    model="gpt-4.1",
    input=user_query
)
print(response.output_text)

OpenAIから返ってきた答え

Certainly! Here’s a gentle, atmospheric 4-chord progression in the key of C major, well-suited for ambient music:

**Chord progression:**
Cmaj7 → Gmaj7 → Amin9 → Fmaj7

Below is the progression formatted in simple MIDI JSON (each chord as a 'note on' array per half-note, at MIDI ticks 0, 480, 960, 1440; 120 BPM assumed, one bar per chord).

```json
{
  "tempo": 120,
  "time_signature": [4, 4],
  "tracks": [
    {
      "name": "Ambient Chords",
      "notes": [
        // Cmaj7 (C, E, G, B)
        {"pitch": 60, "start": 0, "duration": 480, "velocity": 70},
        {"pitch": 64, "start": 0, "duration": 480, "velocity": 70},
        {"pitch": 67, "start": 0, "duration": 480, "velocity": 70},
        {"pitch": 71, "start": 0, "duration": 480, "velocity": 70},

        // Gmaj7 (G, B, D, F#)
        {"pitch": 67, "start": 480, "duration": 480, "velocity": 70},
        {"pitch": 71, "start": 480, "duration": 480, "velocity": 70},
        {"pitch": 74, "start": 480, "duration": 480, "velocity": 70},
        {"pitch": 78, "start": 480, "duration": 480, "velocity": 70},

        // Amin9 (A, C, E, G, B)
        {"pitch": 69, "start": 960, "duration": 480, "velocity": 70},
        {"pitch": 60, "start": 960, "duration": 480, "velocity": 70},
        {"pitch": 64, "start": 960, "duration": 480, "velocity": 70},
        {"pitch": 67, "start": 960, "duration": 480, "velocity": 70},
        {"pitch": 71, "start": 960, "duration": 480, "velocity": 70},

        // Fmaj7 (F, A, C, E)
        {"pitch": 65, "start": 1440, "duration": 480, "velocity": 70},
        {"pitch": 69, "start": 1440, "duration": 480, "velocity": 70},
        {"pitch": 60, "start": 1440, "duration": 480, "velocity": 70},
        {"pitch": 64, "start": 1440, "duration": 480, "velocity": 70}
      ]
    }
  ]
}
```

**Explanation:**
- `"pitch"` is MIDI note number.
- `"start"` is tick (0 = start, 480 = 1 bar, etc.)
- `"duration"` is length in ticks (480 = 1 bar at your tempo).
- Velocity is set moderately for a soft, ambient feel.

Let me know if you want it longer, a different key, or more notes per chord!

実行がめちゃ遅いのだが。。これは初回の対話成立まで時間がかかっている? 連続的な対話であれば応答早いのだろうか??
応答されたMIDI JSONの形式がちょっと違っている(Geminiの場合、eventsだったのが、OpenAIの場合、notesになっている)
コントローラでは応答文字列内のjsonで始まる部分を取り出して、AbletonOSC経由でNOTEを書き込むことになるのだろう。。
当初試作した、上位にLLMを置いて下位にMCP Server(OSC Lib)を置く構造より、上位に自作のコントローラ、下位にLLMとOSC Libを配置する構造の方が自分としてはすんなり来る(自分のコントロール下でシステム全体が作りやすい印象)。

ChatGPTが提示してきた連続対話版

from openai import OpenAI

client = OpenAI()

messages = [
    {"role": "system", "content": "あなたは親切なアシスタントです"}
]

while True:
    user_input = input("You: ")

    if user_input.lower() in ["exit", "quit"]:
        break

    messages.append({"role": "user", "content": user_input})

    response = client.chat.completions.create(
        model="gpt-4o-mini",  # 例
        messages=messages
    )

    reply = response.choices[0].message.content
    print("AI:", reply)

    messages.append({"role": "assistant", "content": reply})

whileの外に、client = OpenAI() を置くのがポイントらしいが、、セッション確立はどこでやっているのやら。。。

*1:さらに毎回文句を言われながらGeminiと対話するのが憂鬱