USB-MIDIキーボードでpyxelの音を鳴らしてみるテスト その2

4和音まで鳴らせるようになり、B4以上の音程を出そうとするとバグっていたので、B4以上の音程は無視するようにしました。
鍵盤を離しても他の音が切れないようにもしてみました。

これで基本的な音鳴らしはできたかな　と思います。

https://x.com/0_game_it/status/1924882876761571489

```
import pyxel
import mido
import time

# Pyxel の初期化
pyxel.init(228, 64, fps=30)
# 同時発音数を 4 に設定 (チャンネル0〜3を使用)
pyxel.NUM_SOUNDS = 4

# MIDI ノート番号から Pyxel のノート文字列を生成する関数
# 返り値: (pyxel_note_string, is_valid_note_for_pyxel)
def midi_to_pyxel_note(midi_note):
    note_names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]

    # MIDIノート60 (C4) が PyxelのC2 になるように調整
    # MIDIノート36 (C2) が PyxelのC0 となるようにオフセットを調整する
    pyxel_octave = (midi_note - 36) // 12

    # ノート名 (C, C#, D, ...) のインデックス
    note_index = midi_note % 12

    # Pyxelの有効なオクターブ範囲 (0-4)
    min_pyxel_octave = 0
    max_pyxel_octave = 4 # B4が最高音なので、オクターブ4まで

    # Pyxelで有効な範囲外かどうかをチェック
    is_valid_for_pyxel = True
    if not (min_pyxel_octave <= pyxel_octave <= max_pyxel_octave):
        is_valid_for_pyxel = False

    # Pyxelの有効なオクターブ範囲にクリップ (表示用。実際の再生はis_valid_for_pyxelで制御)
    # これにより、有効範囲外の音が来てもエラーにならず、最も近いPyxelの有効音にマッピングされる
    pyxel_octave = max(min_pyxel_octave, min(max_pyxel_octave, pyxel_octave))

    result_note = f"{note_names[note_index]}{pyxel_octave}"
    return result_note, is_valid_for_pyxel

# Pyxel のサウンドバンクに初期設定 (矩形波、固定スピード)
fixed_speed = 22050 // 10  # 適当な固定値
initial_tones = "0" # 矩形波
initial_effects = "n" # エフェクトなし
initial_volume = "7" # 最大ボリューム

class App:
    def __init__(self):
        self.inport = None
        self.playing_notes = {}
        # available_channelsはNUM_SOUNDSまで確保
        self.available_channels = list(range(pyxel.NUM_SOUNDS))
        self.channel_to_midi_note = {}

        try:
            input_ports = mido.get_input_names()
            if input_ports:
                self.inport = mido.open_input(input_ports[0])
                print(f"MIDIデバイス '{self.inport.name}' に接続しました。")
            else:
                print("利用可能なMIDI入力デバイスが見つかりませんでした。")

            pyxel.run(self.update, self.draw)
        except OSError as e:
            print(f"MIDIデバイスのオープン中にエラーが発生しました: {e}\n利用可能なデバイス: {mido.get_input_names()}")
        except Exception as e:
            print(f"エラーが発生しました: {e}")
        finally:
            if self.inport:
                self.inport.close()

    def update(self):
        if self.inport:
            received_message = self.inport.poll()
            if received_message:
                if received_message.type == 'note_on':
                    if received_message.velocity > 0:
                        midi_note = received_message.note
                        if midi_note not in self.playing_notes: # 既に再生中のノートでなければ
                            # Pyxelノート文字列と有効性フラグを取得
                            pyxel_note, is_valid_note = midi_to_pyxel_note(midi_note)

                            # Pyxelの有効なノート範囲外なら無視して処理を中断
                            if not is_valid_note:
                                return

                            if self.available_channels: # 空いているチャンネルがあるか
                                channel_id = self.available_channels.pop(0) # 最初の空いているチャンネルを取得
                                sound_id = channel_id # サウンドIDはチャンネルIDと同じにする

                                try:
                                    # Pyxelサウンドにノート情報をセット
                                    pyxel.sounds[sound_id].set(
                                        notes=pyxel_note,
                                        tones=initial_tones,
                                        volumes=initial_volume,
                                        effects=initial_effects,
                                        speed=fixed_speed
                                    )
                                    pyxel.play(channel_id, sound_id) # 指定チャンネルで再生
                                    self.playing_notes[midi_note] = {'channel': channel_id, 'sound_id': sound_id}
                                    self.channel_to_midi_note[channel_id] = midi_note
                                except ValueError as e:
                                    print(f"ValueError during sound set: {e}, pyxel_note: {pyxel_note}, midi_note: {midi_note}")
                                except Exception as e:
                                    print(f"Unexpected error during sound set/play: {e}")
                            else:
                                print(f"No available channels for MIDI note {midi_note}. Max {pyxel.NUM_SOUNDS} notes playing.")

                elif received_message.type == 'note_off':
                    midi_note = received_message.note
                    if midi_note in self.playing_notes:
                        channel_id = self.playing_notes[midi_note]['channel']

                        pyxel.stop(channel_id) # 対応するチャンネルの再生を停止

                        # チャンネルを空きリストに戻す
                        self.available_channels.append(channel_id)
                        self.available_channels.sort() # 昇順にソートして再利用

                        del self.playing_notes[midi_note]
                        del self.channel_to_midi_note[channel_id]

    def draw(self):
        pyxel.cls(0)
        pyxel.text(10, 10, "Playing MIDI Chords (Up to {} notes)".format(pyxel.NUM_SOUNDS), 7)
        # 現在再生中のノートとチャンネルを表示
        y_offset = 30
        for midi_note, info in self.playing_notes.items():
            pyxel_note, _ = midi_to_pyxel_note(midi_note) # drawでは有効性チェックは不要なので捨てる
            pyxel.text(10, y_offset, f"Note: {pyxel_note} (MIDI: {midi_note}) on Channel {info['channel']}", 6)
            y_offset += 8

if __name__ == "__main__":
    App()
```