0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-05-22

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

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

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

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()
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?