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?

【Unity】Unity6.3のScriptableAudioPipelineとMIDI入力でUnityアプリを楽器化してみた【MIDI楽器】

Last updated at Posted at 2025-10-03

前回 Unity6.3b を使ってScriptableAudioPipeline を使ってチップチューン(8bit)音のリアルタイムジェネレーターを作りました。

今回は、そこにMIDI入力を受け付けて、上記のチップチューン音を演奏できる楽器アプリを作ってみました

こんな感じでUnityアプリとMIDIキーボードをつないで音色を切り替えて発音ができます。

MIDI入力

MIDI入力に関しては Unityの高橋keijiroさんの MINIS というパッケージ( と、その依存パッケージのRTMIDI) を使いました。

MINISは名前の通り for Unity Input System なので、新しい方(2025年Inputクラス使う方が古いと思うが)のInputSystem を利用します。

InputSystem + MIDI

MIDIではどの音がどの音量・どのタイミングで鳴るかという情報をとりまとめているので、それをInputSystem 側でイベントを定義して受け取れるようにすれば良いわけです。

MINISの手順書通りにまずはInputSystemの設定をします。

1.ProjectSettingsを開いてInputSystem の子階層にあるSettingsから新しく設定を作ります

00_InputSystem_Settings.png

2. 既存の設定を汚さないようにMIDI用のInputAction のファイルを用意します

01_DuplicateAction.png

3. 再びProjectSettingsに戻りActionMapsの+ボタンでMIDI用のMapを作ります

02_CreateMIDIActionMap.png

4. 新しくActionType がButtonのActionを作ってA0と名付けます

03_NewAction.png

5. 右側のPathを選択して Other->MIDI Device -> Note A-0 を選択

03_SetPath.png
04_Other.png
05_NOte.png

Q.手動じゃ大変では?

はい。大変です。
一般的にピアノの鍵盤は88鍵あるので、あと87個を行うのは大変です。

そこでAIに先ほど作った InputSystem_Actions_MIDI_88.inputactions を投げて、88鍵分対応してもらいましょう

ask.png

result.png

本当に作ってくれました。
2分の思考でAIが作ってくれるなら人間がぽちぽちやるより早いですね。

入力イベントのObserverを用意しよう

これもAIに任せて作ってもらいましょう。

ここではいちいちActionを登録解除とか手間なことはやりたくないのと、イベントドリブンな形でかけるようにするため R3 を使うことにします。

MidiInputObserver.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using R3;
using UnityEngine;
using UnityEngine.InputSystem;

using Minis; // jp.keijiro.minis

/// <summary>
/// Observes MIDI note On/Off in two ways:
/// 1) Input System Action Map ("MIDI" map with 88 keys)
/// 2) Minis.MidiDevice callback events (onWillNoteOn / onWillNoteOff)
///
/// Exposes per-key Down/Up Observables + AnyKey streams.
/// </summary>
public sealed class MidiInputObserver : IDisposable
{
    public readonly struct MidiKeyEvent
    {
        public string Name { get; }            // e.g. "C4" (computed when necessary)
        public int NoteNumber { get; }         // e.g. 60
        public float Value { get; }            // velocity [0..1] or 1/0 for down/up
        public InputActionPhase Phase { get; } // Performed = Down, Canceled = Up
        public double Time { get; }            // Input System time or Time.realtimeSinceStartup

        public MidiKeyEvent(string name, int noteNumber, float value, InputActionPhase phase, double time)
        {
            Name = name;
            NoteNumber = noteNumber;
            Value = value;
            Phase = phase;
            Time = time;
        }

        public override string ToString() => $"{Name}({NoteNumber}) {Phase} v={Value:0.###} t={Time:0.000}";
    }

    private readonly InputSystem_Actions _actions;
    private readonly CompositeDisposable _disposables = new();

    // Per-key streams(キー名で引ける/例:"C4", "Cs4"...)
    private readonly Dictionary<string, Subject<MidiKeyEvent>> _keyDown = new();
    private readonly Dictionary<string, Subject<MidiKeyEvent>> _keyUp   = new();

    // 全キー合流
    private readonly Subject<MidiKeyEvent> _anyKeyDown = new();
    private readonly Subject<MidiKeyEvent> _anyKeyUp   = new();

    public Observable<MidiKeyEvent> OnKeyDown(string key) => _keyDown[key];
    public Observable<MidiKeyEvent> OnKeyUp(string key) => _keyUp[key];

    public Observable<MidiKeyEvent> AnyKeyDown => _anyKeyDown;
    public Observable<MidiKeyEvent> AnyKeyUp   => _anyKeyUp;


    // --- Minis device subscriptions bookkeeping ---
    private readonly List<MidiDevice> _attachedDevices = new();

    public MidiInputObserver(InputSystem_Actions actions)
    {
        _actions = actions ?? throw new ArgumentNullException(nameof(actions));

        // 1) InputAction 経由のフック
        EnableActionMapBindings();

        // 2) Minis の生コールバックにフック(既存デバイス+以後追加)
        AttachToExistingMidiDevices();
        InputSystem.onDeviceChange += OnDeviceChange;
    }

    private void EnableActionMapBindings()
    {
        _actions.MIDI.Enable();

        // すべてのアクションを列挙して Down/Up に接続
        var midiMap = _actions.MIDI.Get();
        foreach (var act in midiMap.actions)
        {
            if (act == null) continue;

            var actionName = act.name; // 例: "C4"
            var noteNumber = TryGetNoteNumber(act, out var n) ? n : GuessNoteNumberByName(actionName);

            // Subject を用意
            var down = new Subject<MidiKeyEvent>().AddTo(_disposables);
            var up   = new Subject<MidiKeyEvent>().AddTo(_disposables);
            _keyDown[actionName] = down;
            _keyUp[actionName]   = up;

            // イベント登録
            act.performed += ctx =>
            {
                var val = ReadValueSafe(ctx);
                var e = new MidiKeyEvent(actionName, noteNumber, val, InputActionPhase.Performed, ctx.time);
                down.OnNext(e);
                _anyKeyDown.OnNext(e);
            };
            act.canceled += ctx =>
            {
                var val = ReadValueSafe(ctx);
                var e = new MidiKeyEvent(actionName, noteNumber, val, InputActionPhase.Canceled, ctx.time);
                up.OnNext(e);
                _anyKeyUp.OnNext(e);
            };

            act.Enable();
        }
    }

    // --- Minis: MidiDevice へのフック ---
    private void AttachToExistingMidiDevices()
    {
        foreach (var dev in InputSystem.devices)
        {
            if (dev is MidiDevice md) AttachToMidiDevice(md);
        }
    }

    private void OnDeviceChange(InputDevice device, InputDeviceChange change)
    {
        if (device is not MidiDevice md) return;

        switch (change)
        {
            case InputDeviceChange.Added:
            case InputDeviceChange.Reconnected:
                AttachToMidiDevice(md);
                break;
            case InputDeviceChange.Removed:
            case InputDeviceChange.Disconnected:
                DetachFromMidiDevice(md);
                break;
        }
    }

    private void AttachToMidiDevice(MidiDevice md)
    {
        if (md == null || _attachedDevices.Contains(md)) return;

        // Minis 公式: onWillNoteOn / onWillNoteOff はコントロール更新前に呼ばれる
        // 引数は (MidiNoteControl note, float velocity) / (MidiNoteControl note)
        md.onWillNoteOn += OnWillNoteOn;
        md.onWillNoteOff += OnWillNoteOff;

        _attachedDevices.Add(md);
    }

    private void DetachFromMidiDevice(MidiDevice md)
    {
        if (md == null) return;
        try
        {
            md.onWillNoteOn -= OnWillNoteOn;
            md.onWillNoteOff -= OnWillNoteOff;
        }
        catch { /* ignore */ }
        _attachedDevices.Remove(md);
    }

    private void OnWillNoteOn(MidiNoteControl note, float velocity)
    {
        int num = note.noteNumber;
        string name = TryGetNoteName(note) ?? NoteNumberToName(num);
        var e = new MidiKeyEvent(name, num, Mathf.Clamp01(velocity), InputActionPhase.Performed, Time.realtimeSinceStartupAsDouble);

        // 個別ストリーム(存在しない場合は遅延作成)
        if (!_keyDown.TryGetValue(name, out var down))
        {
            down = new Subject<MidiKeyEvent>().AddTo(_disposables);
            _keyDown[name] = down;
        }
        down.OnNext(e);
        _anyKeyDown.OnNext(e);
    }

    private void OnWillNoteOff(MidiNoteControl note)
    {
        int num = note.noteNumber;
        string name = TryGetNoteName(note) ?? NoteNumberToName(num);
        var e = new MidiKeyEvent(name, num, 0f, InputActionPhase.Canceled, Time.realtimeSinceStartupAsDouble);

        if (!_keyUp.TryGetValue(name, out var up))
        {
            up = new Subject<MidiKeyEvent>().AddTo(_disposables);
            _keyUp[name] = up;
        }
        up.OnNext(e);
        _anyKeyUp.OnNext(e);
    }

    // --- Helpers ---
    private static string TryGetNoteName(MidiNoteControl note)
    {
        // Minis の NoteControl には displayName が入ることがある(例: "C4")。
        // 無ければ null を返し、数値から生成する。
        var s = note?.displayName;
        return string.IsNullOrEmpty(s) ? null : s;
    }

    private static float ReadValueSafe(InputAction.CallbackContext ctx)
    {
        try { return ctx.ReadValue<float>(); }
        catch { return ctx.phase == InputActionPhase.Performed ? 1f : 0f; }
    }

    private static readonly Regex NoteRegex = new(@"note(?<num>\d{3})", RegexOptions.Compiled);

    private static bool TryGetNoteNumber(InputAction action, out int noteNumber)
    {
        foreach (var b in action.bindings)
        {
            if (string.IsNullOrEmpty(b.path)) continue;
            var m = NoteRegex.Match(b.path);    // "<MidiDevice>/note060" など
            if (m.Success && int.TryParse(m.Groups["num"].Value, out var n))
            {
                noteNumber = n;
                return true;
            }
        }
        noteNumber = -1;
        return false;
    }

    private static int GuessNoteNumberByName(string name)
    {
        if (string.IsNullOrEmpty(name)) return -1;

        // 末尾の桁をオクターブとして抽出
        int i = name.Length - 1;
        while (i >= 0 && char.IsDigit(name[i])) i--;
        var notePart = name.Substring(0, i + 1); // "Cs"
        var octPart  = name.Substring(i + 1);    // "4"
        if (!int.TryParse(octPart, out var octave)) return -1;

        int semitone = notePart switch
        {
            "C"  => 0,  "Cs" => 1,
            "D"  => 2,  "Ds" => 3,
            "E"  => 4,
            "F"  => 5,  "Fs" => 6,
            "G"  => 7,  "Gs" => 8,
            "A"  => 9,  "As" => 10,
            "B"  => 11,
            _    => -1,
        };
        if (semitone < 0) return -1;

        // 本プロジェクトの命名前提: C1=24 -> C0=12
        const int C0 = 12;
        return C0 + octave * 12 + semitone;
    }

    private static string NoteNumberToName(int noteNumber)
    {
        // 12 = C0 として逆変換
        int x = Mathf.Max(0, noteNumber - 12);
        int octave = x / 12;
        int semitone = x % 12;
        return semitone switch
        {
            0  => $"C{octave}",
            1  => $"Cs{octave}",
            2  => $"D{octave}",
            3  => $"Ds{octave}",
            4  => $"E{octave}",
            5  => $"F{octave}",
            6  => $"Fs{octave}",
            7  => $"G{octave}",
            8  => $"Gs{octave}",
            9  => $"A{octave}",
            10 => $"As{octave}",
            11 => $"B{octave}",
            _  => noteNumber.ToString(),
        };
    }

    public void Dispose()
    {
        // 完了通知
        foreach (var s in _keyDown.Values) s.OnCompleted();
        foreach (var s in _keyUp.Values) s.OnCompleted();
        _anyKeyDown.OnCompleted();
        _anyKeyUp.OnCompleted();

        // Minis デバイスからデタッチ
        InputSystem.onDeviceChange -= OnDeviceChange;
        foreach (var md in _attachedDevices)
        {
            DetachFromMidiDevice(md);
        }
        _attachedDevices.Clear();

        _disposables.Dispose();
    }
}

これで、その辺のMonoBehaviour 派生クラスで

hoge.cs
    readonly CompositeDisposable disposables = new();
    void Awake()
    {
        var actions = new InputSystem_Actions().AddTo(disposables);
        var midi = new MidiInputObserver(actions).AddTo(disposables);
    
        // すべてのキーをまとめて購読
        midi.AnyKeyDown.Subscribe(e =>
        {
            Debug.Log($"ANY Down {e}");
        }).AddTo(disposables);
        
        midi.AnyKeyUp.Subscribe(e =>
        {
            Debug.Log($"ANY Up   {e}");
        }).AddTo(disposables);
    }
    
    void OnDestroy()
    {
        disposables.Dispose();
    }

こんな感じでかいてあげればイベントが購読できます。

MIDI入力と音源Generator を紐づける

以前の記事で8bit音源ジェネレーターは作りましたが常時音が鳴りっぱなしなので、Sendするパラメータに IsEnabled を追加して、IsEnabledならオーディオバッファに波形を設定、そうでなければ波形は0埋めする対応にしましょう。

ISynthesizer.cs
public interface ISynthsizer
{
    void SetActive(bool active);
    void SetFrequency(float frequency);
}

こんな感じで共通のインターフェースを作って三角波・矩形波コントローラーに組み込んであげましょう。

TriWaveGeneratorControl.cs
using R3;
using UnityEngine;
using UnityEngine.Audio;

namespace TriWave
{

    [RequireComponent(typeof(AudioSource))]
    public class TriWaveGeneratorControl : MonoBehaviour, ISynthsizer
    {
        [SerializeField] AudioSource m_AudioSource;

        ReactiveProperty<float> Frequency = new(432);
        ReactiveProperty<bool> IsActive = new(false);
        void Reset()
        {
            TryGetComponent(out m_AudioSource);
        }

        private void Awake()
        {
            m_AudioSource ??= GetComponent<AudioSource>();
            Observable.Merge(Frequency.Skip(1).Select(_ => 0),
                IsActive.Skip(1).Select(_ => 0))
                .Where(_ => m_AudioSource.isPlaying)
                .Where(_ => ControlContext.builtIn.Exists(m_AudioSource.generatorHandle))
                .Subscribe(_ =>
                    {
                        var handle = m_AudioSource.generatorHandle;
                        ControlContext.builtIn.SendData(handle,
                            new TriangleWaveGenerator.Processor.FrequencyData(Frequency.CurrentValue, IsActive.CurrentValue));
                    }
                ).AddTo(this);
        }
        
        public void SetFrequency(float frequency)
        {
            Frequency.Value =  Mathf.Clamp(frequency, 20f, 22050f);
        }

        public void SetActive(bool active)
        {
            IsActive.Value = active;
        }
    }

}

Inspector で周波数操作していた部分もMIDI入力からもらうイベントで書き換えるようにしました。

TriWaveGenerator.cs
()
 public Generator.Result Process(in ProcessingContext ctx,
            UnityEngine.Audio.Processor.Pipe pipe, ChannelBuffer buffer, Generator.Arguments args)
        {
            int frames = buffer.frameCount;
            int channels = buffer.channelCount;
            float sr = m_Setup.sampleRate;

            m_target = isEnabled ? 1f : 0f;

            for (int frame = 0; frame < frames; frame++)
            {
                // ---- 1) エンベロープ ----
                if (m_target > m_env)      m_env = Mathf.Min(1f, m_env + 1f / m_attackSamples);
                else if (m_target < m_env) m_env = Mathf.Max(0f, m_env - 1f / m_releaseSamples);

                // ---- 2) 波形 ----
                float tri;

               // naive triangle
               float phase = m_Phase - Mathf.Floor(m_Phase);
               tri = 4f * Mathf.Abs(phase - 0.5f) - 1f;

                // ---- 3) エンベロープ適用 ----
                float vOut = tri * m_env;

                for (int ch = 0; ch < channels; ch++)
                    buffer[ch, frame] = vOut;

                // ---- 4) 位相進行 ----
                m_Phase += m_Frequency / sr;
                if (m_Phase >= 1f) m_Phase -= 1f;
            }
            return frames;
        }

最初0埋めで良いかなと思ったのですが、試したところ音の立ち上がりとキーを話した時に波形の不連続点が生じることからプツッっとノイズが鳴ってしまうのがかなり気になったので上記のように少し波形のエンベロープに細工を入れてノイズ対策を行いました。

音源の切り替え

シンプルにuGUIのToggle+ToggleGroup を利用します。

hoge.cs
public class MidiListenerSample : MonoBehaviour
{
    CompositeDisposable disposables = new();
    [Header("Synth Controllers")]
    [SerializeField] private SquareWaveGeneratorControl sqrWaveController = null;
    [SerializeField] private TriWaveGeneratorControl triWaveController = null;
    
    [Header("Toggle")]
    [SerializeField] private Toggle[] toggles = null;

    ReactiveProperty<ISynthsizer> activeSynth = new(null);
    void Awake()
    {
        var actions = new InputSystem_Actions().AddTo(disposables);
        var midi = new MidiInputObserver(actions).AddTo(disposables);
        activeSynth.Value = sqrWaveController;

        for (int i = 0; i < toggles.Length; i++)
        {
            int index = i;
            toggles[index].OnValueChangedAsObservable()
                .Where(isOn => isOn)
                .Subscribe(_ =>
                {
                    activeSynth.Value = index == 0 ? sqrWaveController : triWaveController;
                })
                .AddTo(disposables);
        }
        // すべてのキーをまとめて購読
        midi.AnyKeyDown.Subscribe(e =>
        {
            Debug.Log($"ANY Down {e}");
            OnKeyDown(e, activeSynth.CurrentValue);
        }).AddTo(disposables);
        
        midi.AnyKeyUp.Subscribe(e =>
        {
            Debug.Log($"ANY Up   {e}");
            OnKeyUp(e, activeSynth.CurrentValue);
        }).AddTo(disposables);
        
        activeSynth
            .Skip(1)// SkipOnSubscribe
            .Pairwise()
            .Subscribe(pair =>
            {
                if (pair.Previous != null)
                {
                    pair.Previous.SetActive(false);
                }
            })
            .AddTo(disposables);
    }

    void OnKeyDown(MidiInputObserver.MidiKeyEvent ev, ISynthsizer synth)
    {
        counter++;
        synth.SetFrequency(NoteNumberToFrequency(ev.NoteNumber));
        synth.SetActive(counter> 0);
    }

    void OnKeyUp(MidiInputObserver.MidiKeyEvent ev, ISynthsizer synth)
    {
        counter = Mathf.Max(0, counter - 1);
        synth.SetActive(counter> 0);
    }

    private float NoteNumberToFrequency(int noteNumber)
    {
        return 440f * Mathf.Pow(2f, (noteNumber - 69f) / 12f);
    }
    void OnDestroy()
    {
        disposables.Dispose();
    }
}

こんな感じでかいてあげれば利用する音源の切り替えができますね。

実際に試してみた

今回は iPad 向けにビルド(ちょっとXCodeのビルドエラーが出たのでAIを用いて修正 )して
MIDI入力では KORG Microkey Air2 61鍵のBluetooth 接続モードで試しました。

Bluetooth接続のため押してから反映までラグが結構気になりますが、とりあえず鍵盤の音を鳴らすところまではいけました

今後

今回はButtonしか登録してないので音量制御などもMIDI入力から拾って反映したり、同時発音数でいうとGenerator側が和音非対応なので、そこを拡張したりとまだまだ改善が必要です。

しかし、従来みたいに音源を個別で用意しなくてもSoundGenerator によるプロシージャルオーディオの力で音を色々再生できる仕組みは非常に強力です。

引き続きUnity6.3には注目ですね。

付録

今回のコード

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?