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
midion commandopen_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) を参照してください。
