MIDI 機器と入出力する(midir)

midir で MIDI キーボードの入力を受けてイベントで JS に送り、シンセや DAW へノートを送信する。接続を State に保持する方法、ポートの選び方、Web MIDI API との使い分けも示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約10分 hw-014
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. 2. フロントエンドから呼び出す (TypeScript)
  4. Web MIDI API との使い分け
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

MIDI キーボードやパッドの操作でアプリを動かす、アプリからシンセサイザーや DAW へノートを送る、といった用途では Rust の midir クレートを使います。Tauri 自体には MIDI の API がありません。入力はコールバックで受け取ってイベントで JS に送り、出力は開いた接続を State に持っておいて、コマンドから送信します。WebView の Web MIDI API は OS によって使えないため、どの OS でも動かすなら Rust 側で扱います。

前提条件

src-tauri で midir を追加します。コードは midir 0.11 系向けです。

cd src-tauri
cargo add midir

自作コマンドの invoke と、core:default に含まれる core:event:default での listen だけなので、capability の追加は不要です。Linux でのビルドには ALSA の開発用ファイル(Debian / Ubuntu は libasound2-dev)が要ります。マイクと違い、macOS でも Info.plist に説明文を書く必要はありません。

1. バックエンドから実装する (Rust)

押さえる点は 3 つです。

  • ポートは番号で覚えない: ports() の並び順は機器の抜き差しで変わります。id() の文字列で指定しますが、Windows では id が空のポート(内蔵の「Microsoft GS Wavetable Synth」など)があるので、その場合は名前で代用します。
  • 接続は持ち続ける: connect() が返す接続は drop した時点で閉じます。入力も出力も State に入れておき、閉じるときに取り出します(State と Mutex でアプリの状態を管理する)。開き直すときは、先に古い接続を閉じます。
  • コールバックは別スレッド: 受信のコールバックは MIDI 用の別スレッドで呼ばれます(重い処理を別スレッド・非同期で実行する)。そこから emit で JS に送ります。テンポに合わせて 1 秒に数十回届くタイミングクロックや、定期的に届くアクティブセンシングは、ignore() で捨てておきます。
use std::sync::Mutex;
use midir::{Ignore, MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};

const CLIENT: &str = "my-tauri-app";

#[derive(Serialize)]
struct MidiPort {
    key: String,  // 接続に使う文字列
    name: String, // 表示用
}

#[derive(Serialize)]
struct MidiPorts {
    inputs: Vec<MidiPort>,
    outputs: Vec<MidiPort>,
}

#[derive(Clone, Serialize)]
struct MidiMessage {
    stamp: u64,     // マイクロ秒(起点は接続ごとに任意)
    bytes: Vec<u8>, // 例: [0x90, 60, 100] = ch1 でノート 60 を強さ 100 で鳴らす
}

/// 開いている接続。drop するとポートが閉じる
#[derive(Default)]
struct Midi {
    input: Mutex<Option<MidiInputConnection<()>>>,
    output: Mutex<Option<MidiOutputConnection>>,
}

/// id が空のポートは名前で代用する
fn port_key(id: String, name: &str) -> String {
    if id.is_empty() { format!("name:{name}") } else { id }
}

#[tauri::command]
fn list_midi_ports() -> Result<MidiPorts, String> {
    let midi_in = MidiInput::new(CLIENT).map_err(|e| e.to_string())?;
    let midi_out = MidiOutput::new(CLIENT).map_err(|e| e.to_string())?;
    let inputs = midi_in
        .ports()
        .iter()
        .filter_map(|p| {
            let name = midi_in.port_name(p).ok()?;
            Some(MidiPort { key: port_key(p.id(), &name), name })
        })
        .collect();
    let outputs = midi_out
        .ports()
        .iter()
        .filter_map(|p| {
            let name = midi_out.port_name(p).ok()?;
            Some(MidiPort { key: port_key(p.id(), &name), name })
        })
        .collect();
    Ok(MidiPorts { inputs, outputs })
}

#[tauri::command]
fn open_midi_input(app: AppHandle, midi: State<'_, Midi>, key: String) -> Result<(), String> {
    let mut slot = midi.input.lock().map_err(|e| e.to_string())?;
    slot.take(); // 前の接続を先に閉じる

    let mut midi_in = MidiInput::new(CLIENT).map_err(|e| e.to_string())?;
    midi_in.ignore(Ignore::TimeAndActiveSense);
    let port = midi_in
        .ports()
        .into_iter()
        .find(|p| midi_in.port_name(p).is_ok_and(|n| port_key(p.id(), &n) == key))
        .ok_or("入力ポートが見つかりません")?;

    let conn = midi_in
        .connect(
            &port,
            "input",
            move |stamp, bytes, _| {
                // MIDI 用の別スレッドで呼ばれる
                let _ = app.emit("midi-message", MidiMessage { stamp, bytes: bytes.to_vec() });
            },
            (),
        )
        .map_err(|e| e.to_string())?;
    *slot = Some(conn);
    Ok(())
}

#[tauri::command]
fn open_midi_output(midi: State<'_, Midi>, key: String) -> Result<(), String> {
    let mut slot = midi.output.lock().map_err(|e| e.to_string())?;
    if let Some(old) = slot.take() {
        close_output(old);
    }
    let midi_out = MidiOutput::new(CLIENT).map_err(|e| e.to_string())?;
    let port = midi_out
        .ports()
        .into_iter()
        .find(|p| midi_out.port_name(p).is_ok_and(|n| port_key(p.id(), &n) == key))
        .ok_or("出力ポートが見つかりません")?;
    *slot = Some(midi_out.connect(&port, "output").map_err(|e| e.to_string())?);
    Ok(())
}

#[tauri::command]
fn send_midi(midi: State<'_, Midi>, bytes: Vec<u8>) -> Result<(), String> {
    let mut slot = midi.output.lock().map_err(|e| e.to_string())?;
    let conn = slot.as_mut().ok_or("出力ポートを開いていません")?;
    conn.send(&bytes).map_err(|e| e.to_string())
}

/// 全チャンネルに All Notes Off(CC 123)を送ってから閉じ、鳴りっぱなしを防ぐ
fn close_output(mut conn: MidiOutputConnection) {
    for ch in 0..16u8 {
        let _ = conn.send(&[0xB0 | ch, 123, 0]);
    }
    conn.close();
}

#[tauri::command]
fn close_midi(midi: State<'_, Midi>) -> Result<(), String> {
    midi.input.lock().map_err(|e| e.to_string())?.take();
    if let Some(conn) = midi.output.lock().map_err(|e| e.to_string())?.take() {
        close_output(conn);
    }
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(Midi::default())
        .invoke_handler(tauri::generate_handler![
            list_midi_ports,
            open_midi_input,
            open_midi_output,
            send_midi,
            close_midi
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

2. フロントエンドから呼び出す (TypeScript)

リスナーを先に登録してからポートを開きます。受け取るのは生のバイト列なので、1 バイト目の上位 4 ビットで種類(0x90 が Note On、0x80 が Note Off、0xB0 がコントロールチェンジ)、下位 4 ビットでチャンネルを読み分けます。強さ 0 の Note On は Note Off として扱います。送るときも同じ形で、Note On を送ったら必ず Note Off を送ります。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type MidiPort = { key: string; name: string };
type MidiPorts = { inputs: MidiPort[]; outputs: MidiPort[] };
type MidiMessage = { stamp: number; bytes: number[] };

await listen<MidiMessage>('midi-message', ({ payload }) => {
  const [status, data1, data2] = payload.bytes;
  const type = status & 0xf0;
  const ch = (status & 0x0f) + 1; // 画面に出すチャンネルは 1〜16
  if (type === 0x90 && data2 > 0) {
    console.log(`Note On  ch${ch} note=${data1} vel=${data2}`);
  } else if (type === 0x80 || type === 0x90) {
    console.log(`Note Off ch${ch} note=${data1}`);
  } else if (type === 0xb0) {
    console.log(`CC       ch${ch} #${data1}=${data2}`);
  }
});

const ports = await invoke<MidiPorts>('list_midi_ports');
console.table(ports.inputs);
console.table(ports.outputs);
const input = ports.inputs[0];
if (input) await invoke('open_midi_input', { key: input.key });
const output = ports.outputs.find((p) => p.name.includes('GS Wavetable')) ?? ports.outputs[0];
if (output) await invoke('open_midi_output', { key: output.key });

// ノートを鳴らし、ms ミリ秒後に Note Off を送る(channel は 0〜15)
export async function playNote(note: number, velocity = 100, ms = 500, channel = 0) {
  await invoke('send_midi', { bytes: [0x90 | channel, note, velocity] });
  window.setTimeout(() => void invoke('send_midi', { bytes: [0x80 | channel, note, 0] }), ms);
}

export const closeMidi = () => invoke('close_midi');

選んだポートの key を保存しておけば、次回起動時も同じ機器を開けます。一覧に無ければ、その機器はつながっていません。

Web MIDI API との使い分け

WebView の Web MIDI API(navigator.requestMIDIAccess())は Windows の WebView(Chromium 系)には用意されていますが、macOS と Linux の WebView にはありません。Windows 専用のアプリなら JS だけで完結できる場合もあります。複数の OS に配るなら、上の Rust 側の方法にそろえます。

// Web MIDI API が使える WebView か確かめる(使えなければ Rust 側の midir を使う)
export async function hasWebMidi(): Promise<boolean> {
  if (!('requestMIDIAccess' in navigator)) return false;
  try {
    await navigator.requestMIDIAccess();
    return true;
  } catch {
    return false; // 許可されなかった
  }
}

動作確認

npm run tauri dev で起動します。Windows には出力ポート「Microsoft GS Wavetable Synth」が最初からあるので、機器が無くても playNote(60) でスピーカーからピアノの音が鳴ります。MIDI キーボードをつないで鍵盤を押すと、コンソールに次のように出ます。

Note On  ch1 note=60 vel=87
Note Off ch1 note=60
CC       ch1 #64=127

#64 はサステインペダルです。ペダルを離すと値が 0 のメッセージが届きます。

よくあるエラーと対処法

  • 「could not create Windows MM MIDI input port (MMSYSERR_ALLOCATED)」: DAW など別のアプリがそのポートを使っています。Windows では 1 つのポートを複数のアプリで同時に開けないことがあるので、相手のアプリで使用をやめてから開き直します。出力でも同様のエラーになります。
  • 「入力ポートが見つかりません」・「invalid port」: 前者は例のコードのメッセージ、後者は接続の直前にポートが無くなったときのものです。機器が外されているので、一覧を取り直します。
  • 「state not managed for field midi on command open_midi_input. You must call .manage() before using this command」: .manage(Midi::default()) を忘れています。
  • 「non-sysex message must not be longer than 3 bytes」: Windows で、SysEx(0xF0 で始まるメッセージ)以外に 4 バイト以上を送っています。1 回の send には 1 メッセージだけを入れます。
  • 音が鳴りっぱなしになる: Note Off を送る前に接続を閉じたか、アプリが終了しました。例の close_output() のように、閉じる前に All Notes Off を送ります。

OS ごとの違いと注意点

  • Windows: midir は仮想ポートを作れません。同じ PC の DAW へ送るには、loopMIDI や LoopBe1 などの仮想 MIDI ポートを入れ、その出力ポートを開きます。
  • macOS / Linux: midir::os::unix の create_virtual() で、アプリ自身を DAW から見える仮想ポートにできます。macOS の IAC ドライバ(「Audio MIDI 設定」で有効にする)を経由する方法もあります。
  • 抜き差し: midir には切断を知らせる仕組みがありません。一覧を定期的に取り直し、接続中の key が消えたら閉じて画面で知らせます。
  • MIDI は音ではない: 送るのは「どの鍵盤をどの強さで」という信号で、鳴らすのは受け取った音源です。アプリ自身で音を鳴らすなら 音声の出力スピーカーを切り替える、演奏の音を録るなら マイクから音声を録音する を使います。
  • イベントの受け取り方と解除は Rust からのイベントを受信する (listen) を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する