音声の出力スピーカーを切り替える

rodio で出力デバイスの一覧を取り、選んだスピーカーやヘッドホンで音声ファイルを再生する。再生中の切り替えと選択の保存、HTML の audio 要素の setSinkId() との違いも示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約11分 hw-013
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 一覧を取り、選んだデバイスで再生する
  5. フロントエンドから呼び出す
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

「通知音はノート 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
デバイスの IDWebView 独自(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, &current.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, found cpal::Device」: 続けて「two different versions of crate cpal are 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))。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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