「通知音はノート PC のスピーカー、読み上げはヘッドセット」「店頭端末の案内音声だけ天井スピーカーへ」のように、OS の既定とは別の出力先で鳴らしたい場面があります。方法は 2 つで、HTML の <audio> 要素なら setSinkId()、Rust なら rodio クレートで出力デバイスの一覧を取り、選んだデバイスで再生します。このレシピは rodio を中心に、一覧の表示、選択の保存、再生中の切り替えまでを示します。
前提条件
src-tauri で rodio を追加します。コードは rodio 0.22 系向けです(0.21 の OutputStreamBuilder / Sink は、0.22 で DeviceSinkBuilder / Player に名前が変わりました)。既定の機能で MP3・FLAC・Ogg Vorbis・WAV・MP4(AAC)を再生できます。
cd src-tauri
cargo add rodio
rodio 0.22 は内部で cpal 0.17 系を使い、それを rodio::cpal として再公開しています。デバイスの一覧や選択には必ず rodio::cpal を使います。マイクから音声を録音する のように cpal 0.18 を直接入れたプロジェクトでも、rodio に渡すデバイスを cpal::default_host() から取ると型が合いません。
自作コマンドの invoke と core:default に含まれる listen だけなので、capability の追加は不要です。Linux でのビルドには ALSA の開発用ファイル(libasound2-dev など)が要ります。
2 つの方法の違いは次のとおりです。
<audio> の setSinkId() | Rust(rodio) | |
|---|---|---|
| 出力先の一覧 | enumerateDevices()。マイクの許可前は名前と ID が空のことがある | 許可なしで名前と ID を取れる |
| 使える環境 | setSinkId() がある WebView だけ | Windows / macOS / Linux |
| デバイスの ID | WebView 独自(Rust 側の ID とは別物) | 再起動後も基本的に同じ |
| 鳴らせる間 | ページを開いている間 | アプリが動いている間 |
どちらも切り替わるのは自分の再生だけで、OS の既定の出力は変わりません。
1. フロントエンドから実装する (TypeScript)
WebView の中で完結させるなら setSinkId() を使います。先に機能の有無を確かめ、無ければ Rust 側で再生します。空文字列を渡すと既定のデバイスに戻ります。
// <audio> 要素だけで出力先を選ぶ(setSinkId() がある WebView のみ)
export async function listSpeakers(): Promise<MediaDeviceInfo[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((d) => d.kind === 'audiooutput');
}
export async function playOn(audio: HTMLAudioElement, deviceId: string) {
if (!('setSinkId' in HTMLMediaElement.prototype)) {
throw new Error('この WebView では setSinkId() を使えません'); // Rust 側で再生する
}
await audio.setSinkId(deviceId);
await audio.play();
}
const speakers = await listSpeakers();
console.table(speakers.map((d) => ({ label: d.label, id: d.deviceId })));
if (speakers[1]) await playOn(new Audio('/sounds/chime.mp3'), speakers[1].deviceId);
名前(label)と ID が空で返る場合は、先に getUserMedia({ audio: true }) でマイクの許可を得る必要があります。スピーカーを選ぶだけでマイクの許可を求めることになるので、ユーザーが戸惑わないか考えてから選びます。
2. バックエンドから実装する (Rust)
一覧を取り、選んだデバイスで再生する
rodio では「デバイスを選ぶ → DeviceSinkBuilder で出力を開く → Player に音を入れる」の順に進めます。開いた出力(MixerDeviceSink)と Player は、drop した時点で音が止まります。コマンドのローカル変数のままだと鳴った瞬間に止まり、sleep_until_end() で終わりまで待つと、その間コマンドが返らず画面も待たされます。どちらも避けるため、再生中の組を State に入れておきます(State と Mutex でアプリの状態を管理する)。
デバイスの指定と保存には名前ではなく ID を使います。Windows では名前が「スピーカー」だけになり、同じ名前が並ぶことがあるので、表示名には機器名(driver())を添えます。再生中に切り替えたときは、新しいデバイスで開き直して get_pos() の位置へ try_seek() し、続きから鳴らします。
use std::fs::File;
use std::str::FromStr;
use std::sync::Mutex;
use std::time::Duration;
use rodio::cpal::traits::HostTrait;
use rodio::{Decoder, DeviceSinkBuilder, DeviceTrait, MixerDeviceSink, Player};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OutputDevice {
id: String, // 指定と保存に使う
name: String, // 表示用
is_default: bool,
}
/// 再生中の 1 曲分。sink(出力先とのつながり)を drop すると音が止まる
struct Playback {
sink: MixerDeviceSink,
player: Player,
path: String,
}
#[derive(Default)]
struct AudioOut {
device_id: Mutex<Option<String>>, // None なら OS の既定
playback: Mutex<Option<Playback>>,
}
#[tauri::command]
fn list_output_devices() -> Result<Vec<OutputDevice>, String> {
let host = rodio::cpal::default_host();
let default_id = host.default_output_device().and_then(|d| d.id().ok());
let devices = host.output_devices().map_err(|e| e.to_string())?;
Ok(devices
.filter_map(|d| {
let id = d.id().ok()?;
let desc = d.description().ok()?;
// Windows では name() が「スピーカー」だけのことがあるので機器名を添える
let name = match desc.driver() {
Some(drv) if !desc.name().contains(drv) => format!("{} ({})", desc.name(), drv),
_ => desc.name().to_string(),
};
let is_default = default_id.as_ref() == Some(&id);
Some(OutputDevice { id: id.to_string(), name, is_default })
})
.collect())
}
fn find_device(id: Option<&str>) -> Result<rodio::Device, String> {
let host = rodio::cpal::default_host();
match id {
None => host.default_output_device().ok_or_else(|| "既定の出力デバイスがありません".to_string()),
Some(id) => {
let id = rodio::cpal::DeviceId::from_str(id).map_err(|e| e.to_string())?;
host.device_by_id(&id)
.ok_or_else(|| "出力デバイスが見つかりません(外された可能性があります)".to_string())
}
}
}
fn start(app: &AppHandle, path: &str, device_id: Option<&str>, from: Duration) -> Result<Playback, String> {
let app = app.clone();
let mut sink = DeviceSinkBuilder::from_device(find_device(device_id)?)
.map_err(|e| e.to_string())?
.with_error_callback(move |e| {
let _ = app.emit("audio-output-error", e.to_string()); // 再生中に外されたときなど
})
.open_stream()
.map_err(|e| e.to_string())?;
sink.log_on_drop(false); // 止めるたびにターミナルへ出る案内を消す
let player = Player::connect_new(sink.mixer());
let file = File::open(path).map_err(|e| e.to_string())?;
player.append(Decoder::try_from(file).map_err(|e| e.to_string())?);
if !from.is_zero() {
let _ = player.try_seek(from); // シークできない形式なら先頭から
}
Ok(Playback { sink, player, path: path.to_string() })
}
#[tauri::command]
fn play_file(app: AppHandle, audio: State<'_, AudioOut>, path: String) -> Result<(), String> {
let device_id = audio.device_id.lock().map_err(|e| e.to_string())?.clone();
let next = start(&app, &path, device_id.as_deref(), Duration::ZERO)?;
*audio.playback.lock().map_err(|e| e.to_string())? = Some(next); // 前の再生は drop されて止まる
Ok(())
}
#[tauri::command]
fn set_output_device(app: AppHandle, audio: State<'_, AudioOut>, id: Option<String>) -> Result<(), String> {
let mut playback = audio.playback.lock().map_err(|e| e.to_string())?;
// 再生中なら新しいデバイスで開き直し、同じ位置から続ける。失敗したら元のまま鳴り続ける
if let Some(current) = playback.as_ref().filter(|p| !p.player.empty()) {
let next = start(&app, ¤t.path, id.as_deref(), current.player.get_pos())?;
*playback = Some(next);
}
*audio.device_id.lock().map_err(|e| e.to_string())? = id;
Ok(())
}
#[tauri::command]
fn stop_playback(audio: State<'_, AudioOut>) -> Result<(), String> {
audio.playback.lock().map_err(|e| e.to_string())?.take(); // drop で止まる
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AudioOut::default())
.invoke_handler(tauri::generate_handler![
list_output_devices,
set_output_device,
play_file,
stop_playback
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
play_file は受け取ったパスをそのまま開きます。鳴らすのが同梱の効果音だけなら、ファイル名だけを受け取り Rust 側でリソースのフォルダーと結合した方が安全です(画像やDBファイルを配布物に同梱する)。ユーザーのファイルなら ファイルを開くダイアログ で選ばせます。
フロントエンドから呼び出す
一覧を <select> に並べ、選んだ ID を保存して次回も同じデバイスで鳴らします。保存した ID が一覧に無ければ(外されていれば)既定に戻します。
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
type OutputDevice = { id: string; name: string; isDefault: boolean };
const KEY = 'output-device-id';
export async function setupOutputSelect(select: HTMLSelectElement) {
const devices = await invoke<OutputDevice[]>('list_output_devices');
select.replaceChildren(new Option('OS の既定に従う', ''));
for (const d of devices) {
select.add(new Option(d.isDefault ? `${d.name}(現在の既定)` : d.name, d.id));
}
const saved = localStorage.getItem(KEY) ?? '';
select.value = devices.some((d) => d.id === saved) ? saved : '';
await invoke('set_output_device', { id: select.value || null });
select.onchange = async () => {
try {
await invoke('set_output_device', { id: select.value || null });
localStorage.setItem(KEY, select.value);
} catch (e) {
console.error('切り替えに失敗しました:', e); // 元のデバイスで鳴り続ける
select.value = localStorage.getItem(KEY) ?? '';
}
};
}
await listen<string>('audio-output-error', ({ payload }) => {
console.warn('出力デバイスのエラー:', payload);
});
export const playFile = (path: string) => invoke('play_file', { path });
export const stopPlayback = () => invoke('stop_playback');
動作確認
npm run tauri dev で起動し、invoke('list_output_devices') の結果を表示すると、Windows では次のように並びます(ID は長いので途中を省略)。
{ id: "wasapi:{0.0.0.00000000}.{aaaaaaaa-…}", name: "スピーカー (USB Audio Device)", isDefault: true }
{ id: "wasapi:{0.0.0.00000000}.{bbbbbbbb-…}", name: "Monitor (High Definition Audio)", isDefault: false }
playFile() で曲を鳴らしている途中に <select> を変えると、音が新しいデバイスへ移り、ほぼ同じ位置から続きます。
よくあるエラーと対処法
- エラーは出ないのに音が鳴らない:
MixerDeviceSinkかPlayerがコマンドの終わりで drop されています。例のように State に入れて持ち続けます。 - 「expected
rodio::Device, foundcpal::Device」: 続けて「two different versions of cratecpalare being used」と出ます。Cargo.toml に直接入れた cpal のデバイスを rodio に渡しています。rodio::cpal::default_host()から取ります。 - 「The format of the data has not been recognized.」: 対応していない形式か、拡張子と中身が違うファイルです。
- 「出力デバイスが見つかりません(外された可能性があります)」: 例のコードが返すメッセージです。Bluetooth のヘッドホンなどが切断されています。一覧を取り直して選び直します。
- 止めるたびに「Dropping DeviceSink, …」とターミナルに出る: rodio の案内表示です。
log_on_drop(false)で消せます。
OS ごとの違いと注意点
- 共通: OS の既定の出力デバイスを変える機能は rodio にも Tauri にもありません。変わるのはこのアプリの再生だけです。
- Windows: デバイスの名前が「スピーカー」のように短く、同じ名前が複数並ぶことがあります(表示名の作り方は 2 章を参照)。
- Linux: ビルドに ALSA の開発用ファイルが要ります。一覧に同じ機器が別名で複数見えることがあるので、実際に鳴らして確かめられる UI にします。
- 抜き差し: 再生中のデバイスが外されたときの動き(止まる、エラーが届く)は OS によって違います。
audio-output-errorを受けたら一覧を取り直します。 - 録音した WAV を選んだスピーカーで聞き返すなら マイクから音声を録音する と組み合わせます。MIDI の信号は音そのものではないので、鳴らすには音源が必要です(MIDI 機器と入出力する(midir))。
