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


というわけで、コントローラーとキー入力で音色とエフェクトを変更できるようになりました。

自分はコードを書くときにコメントつけながら進める派ではありません
(手直しが多いので最初につけたコメントと最終的なコードに齟齬が生じることが多いので😂)

コードが完成した後にAIにコメントを付与してもらうのですが、ビブラートを「バイブレーション」としているのをみて、音楽に対する知見の深さが滲み出ていて好きw

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

```
import pyxel
import mido
import time

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

# 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まで

    is_valid_note = True 

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

    # Pyxelの有効なオクターブ範囲にクリップ (表示用。実際の再生はis_valid_for_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_note

# Pyxel のサウンドバンクに初期設定 (矩形波、固定スピード)
# speed値は整数である必要がある
# DEFAULT_SPEEDを22050に設定
DEFAULT_SPEED = 22050
INITIAL_VOLUME = "7" # 最大ボリューム

# トーンの種類とエフェクトの種類をリストで定義
TONES = ["0", "1", "2", "3"] # 0:矩形波, 1:疑似三角波, 2:疑似鋸波, 3:正弦波
EFFECTS = ["n", "f", "v"] # n:なし, f:フェードアウト, v:バイブレーション

# フェードアウトのスピード選択肢と名前
# Pyxelのspeedは値が小さいほど「速い」ことに注意
FADE_OUT_SPEEDS = [
    {"name": "NORMAL", "speed": 512}, # NORMALを512に設定
    {"name": "FAST", "speed": 256}    # FASTを256に設定 (SLOWを廃止)
]

class App:
    def __init__(self):
        self.inport = None
        self.playing_notes = {}
        self.available_channels = list(range(pyxel.NUM_SOUNDS))
        self.channel_to_midi_note = {}

        self.current_tone_index = 0
        self.current_effect_index = 0
        self.current_fade_out_speed_index = 0

        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 pyxel.btnp(pyxel.KEY_ESCAPE) or pyxel.btnp(pyxel.GAMEPAD1_BUTTON_START):
            pyxel.quit()

        # AキーまたはコントローラーのAボタンでトーン変更 (表示はBボタンに)
        if pyxel.btnp(pyxel.KEY_A) or pyxel.btnp(pyxel.GAMEPAD1_BUTTON_A):
            self.current_tone_index = (self.current_tone_index + 1) % len(TONES)
            print(f"Tone changed to: {TONES[self.current_tone_index]}")

        # ZキーまたはコントローラーのBボタンでエフェクト変更 (表示はAボタンに)
        if pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.GAMEPAD1_BUTTON_B):
            self.current_effect_index = (self.current_effect_index + 1) % len(EFFECTS)
            print(f"Effect changed to: {EFFECTS[self.current_effect_index]}")
            if EFFECTS[self.current_effect_index] != "f":
                self.current_fade_out_speed_index = 0

        # CキーまたはコントローラーのXボタンでフェードアウトスピード変更 (表示はYボタンに)
        if (pyxel.btnp(pyxel.KEY_C) or pyxel.btnp(pyxel.GAMEPAD1_BUTTON_X)) \
           and EFFECTS[self.current_effect_index] == "f":
            self.current_fade_out_speed_index = (self.current_fade_out_speed_index + 1) % len(FADE_OUT_SPEEDS)
            print(f"Fade Out Speed changed to: {FADE_OUT_SPEEDS[self.current_fade_out_speed_index]['name']}")

        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_note, is_valid_note = midi_to_pyxel_note(midi_note)
                            
                            if not is_valid_note:
                                return 

                            if self.available_channels:
                                channel_id = self.available_channels.pop(0)
                                sound_id = channel_id
                                
                                current_speed_val = DEFAULT_SPEED
                                if EFFECTS[self.current_effect_index] == "f":
                                    current_speed_val = FADE_OUT_SPEEDS[self.current_fade_out_speed_index]["speed"]

                                try:
                                    pyxel.sounds[sound_id].set(
                                        notes=pyxel_note, 
                                        tones=TONES[self.current_tone_index],
                                        volumes=INITIAL_VOLUME,
                                        effects=EFFECTS[self.current_effect_index],
                                        speed=current_speed_val
                                    )
                                    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, f"Playing MIDI Chords (Up to {pyxel.NUM_SOUNDS} notes)", 7)
        
        current_tone_name = {
            "0": "SQUARE", "1": "TRIANGLE", "2": "SAW", "3": "SINE"
        }.get(TONES[self.current_tone_index], TONES[self.current_tone_index])

        current_effect_name = {
            "n": "NONE", "f": "FADE_OUT", "v": "VIBRATO"
        }.get(EFFECTS[self.current_effect_index], EFFECTS[self.current_effect_index])

        # 色指定
        pyxel.text(10, 20, f"Tone: {current_tone_name} (A/GAMEPAD_B)", 10) # 黄色
        pyxel.text(10, 30, f"Effect: {current_effect_name} (Z/GAMEPAD_A)", 3) # 緑色
        
        if EFFECTS[self.current_effect_index] == "f":
            fade_speed_name = FADE_OUT_SPEEDS[self.current_fade_out_speed_index]["name"]
            pyxel.text(10, 40, f"Fade Speed: {fade_speed_name} (C/GAMEPAD_Y)", 3) # 緑色
            y_offset = 60
        else:
            y_offset = 50

        for midi_note, info in self.playing_notes.items():
            pyxel_note, _ = midi_to_pyxel_note(midi_note)
            pyxel.text(10, y_offset, f"Note: {pyxel_note} (MIDI: {midi_note}) on Channel {info['channel']}", 6)
            y_offset += 8

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