マイクから音声を録音する

cpal でマイクの音を受け取り hound で WAV に保存する。録音の開始と停止は State で管理する。macOS の Info.plist に書く説明文と、WebView の getUserMedia との使い分けも示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約12分 hw-012
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 録音の開始と停止を State で管理する
  5. フロントエンドから呼び出す
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

音声メモや会議の記録のようにマイクの音をファイルに残す方法は 2 つあります。WebView の getUserMedia() と MediaRecorder なら JS だけで録れますが、保存形式は WebView 任せで、ウィンドウを閉じると録音も止まります。WAV で残したい、ウィンドウと関係なく録り続けたいときは、Rust の cpal で音を受け取り hound で WAV に書きます。このレシピは Rust 側を中心に、録音の開始と停止を State で管理する形を示します。

前提条件

src-tauri でクレートを追加します。コードは cpal 0.18 系向けで、0.17 以前の例とは一部の API が違います(よくあるエラーを参照)。

cd src-tauri
cargo add cpal hound

自作コマンドの invoke と、core:default に含まれる core:event:default での listen しか使わないので、capability の追加は不要です。Linux でビルドするには ALSA の開発用ファイル(Debian / Ubuntu は libasound2-dev、Fedora は alsa-lib-devel)が要ります。

macOS では、マイクを使う理由を src-tauri/Info.plist に書きます。許可ダイアログにこの文が表示され、無いとマイクを使えません(getUserMedia でも cpal でも同じ)。このファイルはビルド時に既定の Info.plist にマージされ、tauri dev の実行ファイルにも埋め込まれます。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSMicrophoneUsageDescription</key>
  <string>音声メモを録音するためにマイクを使います</string>
</dict>
</plist>

署名して配布する場合は bundle.macOS.hardenedRuntime が既定で true なので、エンタイトルメントのファイルに com.apple.security.device.audio-input を true で入れ、bundle.macOS.entitlements で指定します。

{
  "bundle": {
    "macOS": {
      "entitlements": "./Entitlements.plist"
    }
  }
}

2 つの方法の違いは次のとおりです。

WebView(getUserMedia)Rust(cpal + hound)
追加するものなしcpal・hound クレート
保存形式WebView が選ぶ圧縮形式(WebM など)WAV を自分で決める
マイクの許可OS の許可に加え、WebView が確認を出すことがあるOS の許可だけ
ウィンドウを閉じる・再読み込み録音も止まる止まらない
エコー除去・ノイズ抑制制約の指定だけで使える自分で実装する

1. フロントエンドから実装する (TypeScript)

通話や短い音声メモなら WebView だけで足ります。作れる形式は WebView ごとに違うので isTypeSupported() で確かめます。録り終えたらトラックを stop() しないと、マイクを使ったままになり OS の「使用中」の表示も消えません。

// WebView だけで ms ミリ秒録音し、Blob を返す
export async function recordInWebView(ms: number): Promise<Blob> {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: { echoCancellation: true, noiseSuppression: true }, // 通話向けの処理は WebView に任せる
  });
  const mimeType = ['audio/webm', 'audio/mp4', 'audio/ogg'].find((t) => MediaRecorder.isTypeSupported(t));
  const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
  const chunks: Blob[] = [];
  recorder.ondataavailable = (e) => chunks.push(e.data);
  const stopped = new Promise<void>((resolve) => recorder.addEventListener('stop', () => resolve()));

  recorder.start();
  await new Promise((r) => setTimeout(r, ms));
  recorder.stop();
  await stopped;
  stream.getTracks().forEach((t) => t.stop()); // マイクを解放する
  return new Blob(chunks, { type: recorder.mimeType });
}

const blob = await recordInWebView(3000);
void new Audio(URL.createObjectURL(blob)).play(); // その場で聞き返す

できた Blob をファイルにするには arrayBuffer() で取り出して書き込みます(バイナリデータをファイルに保存する)。

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

録音の開始と停止を State で管理する

cpal では「既定の入力デバイス → 既定の設定 → 入力ストリーム → play()」の順に進めます。0.18 のストリームは止まった状態で返るので、play() までは録音されません。ストリームは drop した時点で止まるため、State に入れて持ち続け、停止のコマンドで取り出します(基本は State と Mutex でアプリの状態を管理する)。

音を受け取るクロージャは音声用の別スレッドで呼ばれます(重い処理を別スレッド・非同期で実行する)。WAV の書き込み口は Arc<Mutex<Option<…>>> で共有し、停止時に取り出して finalize() します。WAV は先頭に長さを書く形式なので、これを忘れると長さ 0 のファイルになることがあります。サンプル形式はデバイスごとに違うため 16bit に揃え、録れているか分かるよう 0.1 秒ごとの音量をイベントで送ります(Rust からのイベントを受信する (listen))。

use std::fs::File;
use std::io::BufWriter;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::Sample;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, RunEvent, State};

type SharedWav = Arc<Mutex<Option<hound::WavWriter<BufWriter<File>>>>>;

/// 録音中の 1 回分。stream を drop するとマイクが止まる
struct Recording {
    stream: cpal::Stream,
    wav: SharedWav,
    path: PathBuf,
}

/// 録音していないときは None
#[derive(Default)]
struct Recorder(Mutex<Option<Recording>>);

#[derive(Serialize)]
struct Recorded {
    path: String,
    seconds: f64,
}

/// デバイスのサンプル形式 T を 16bit に変換して書き込むストリームを作る
fn build_stream<T>(
    device: &cpal::Device,
    config: cpal::StreamConfig,
    wav: SharedWav,
    app: AppHandle,
) -> Result<cpal::Stream, cpal::Error>
where
    T: cpal::SizedSample,
    i16: cpal::FromSample<T>,
{
    let err_app = app.clone();
    let mut peak = 0i16;
    let mut last = Instant::now();
    device.build_input_stream(
        config,
        move |data: &[T], _: &cpal::InputCallbackInfo| {
            // 音声用の別スレッドで呼ばれる。停止処理と重なったら、その分は捨てる
            if let Ok(mut guard) = wav.try_lock() {
                if let Some(writer) = guard.as_mut() {
                    for &s in data {
                        let v: i16 = s.to_sample();
                        let _ = writer.write_sample(v);
                        peak = peak.max(v.saturating_abs());
                    }
                }
            }
            if last.elapsed().as_millis() >= 100 {
                let _ = app.emit("recording-level", peak as f32 / i16::MAX as f32); // 0.0〜1.0
                peak = 0;
                last = Instant::now();
            }
        },
        move |err| {
            let _ = err_app.emit("recording-error", err.to_string()); // マイクが外されたときなど
        },
        None,
    )
}

#[tauri::command]
fn start_recording(app: AppHandle, recorder: State<'_, Recorder>) -> Result<String, String> {
    let mut slot = recorder.0.lock().map_err(|e| e.to_string())?;
    if slot.is_some() {
        return Err("すでに録音中です".into());
    }
    let device = cpal::default_host()
        .default_input_device()
        .ok_or("入力デバイス(マイク)が見つかりません")?;
    let supported = device.default_input_config().map_err(|e| e.to_string())?;
    let config = supported.config();

    // 先にストリームを作る(play() するまで止まっている)。形式が未対応ならファイルを作らずに終わる
    let wav: SharedWav = Arc::new(Mutex::new(None));
    let w = wav.clone();
    let stream = match supported.sample_format() {
        cpal::SampleFormat::F32 => build_stream::<f32>(&device, config, w, app.clone()),
        cpal::SampleFormat::I16 => build_stream::<i16>(&device, config, w, app.clone()),
        cpal::SampleFormat::I24 => build_stream::<cpal::I24>(&device, config, w, app.clone()),
        cpal::SampleFormat::I32 => build_stream::<i32>(&device, config, w, app.clone()),
        other => return Err(format!("未対応のサンプル形式です: {other}")),
    }
    .map_err(|e| e.to_string())?;

    // 保存先: <アプリのデータフォルダ>/recordings/rec-<UNIX 秒>.wav
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join("recordings");
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let secs = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|e| e.to_string())?.as_secs();
    let path = dir.join(format!("rec-{secs}.wav"));
    let spec = hound::WavSpec {
        channels: config.channels,
        sample_rate: config.sample_rate,
        bits_per_sample: 16,
        sample_format: hound::SampleFormat::Int,
    };
    let writer = hound::WavWriter::create(&path, spec).map_err(|e| e.to_string())?;
    *wav.lock().map_err(|e| e.to_string())? = Some(writer);

    stream.play().map_err(|e| e.to_string())?;
    *slot = Some(Recording { stream, wav, path: path.clone() });
    Ok(path.to_string_lossy().into_owned())
}

#[tauri::command]
fn stop_recording(recorder: State<'_, Recorder>) -> Result<Recorded, String> {
    let rec = recorder.0.lock().map_err(|e| e.to_string())?.take().ok_or("録音していません")?;
    drop(rec.stream); // 先にマイクを止める
    let writer = rec.wav.lock().map_err(|e| e.to_string())?.take().ok_or("WAV が開かれていません")?;
    let seconds = writer.duration() as f64 / writer.spec().sample_rate as f64;
    writer.finalize().map_err(|e| e.to_string())?; // 長さをヘッダーに書いて閉じる
    Ok(Recorded { path: rec.path.to_string_lossy().into_owned(), seconds })
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(Recorder::default())
        .invoke_handler(tauri::generate_handler![start_recording, stop_recording])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            // 録音中にアプリを終了しても、WAV を閉じてから終わる
            if let RunEvent::Exit = event {
                let _ = stop_recording(app.state::<Recorder>());
            }
        });
}

既定以外のマイクを使うなら host.input_devices() で一覧を取ります。一覧と選択の保存の考え方は 音声の出力スピーカーを切り替える と同じです。

フロントエンドから呼び出す

ボタン 1 つで開始と停止を切り替え、音量を <progress> に出します。リスナーは録音を始める前に登録しておきます。

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

type Recorded = { path: string; seconds: number };

const button = document.querySelector<HTMLButtonElement>('#rec')!;
const meter = document.querySelector<HTMLProgressElement>('#level')!; // <progress id="level" max="1">
let recording = false;

await listen<number>('recording-level', ({ payload }) => {
  meter.value = payload;
});
await listen<string>('recording-error', ({ payload }) => {
  console.error('録音エラー:', payload);
});

button.addEventListener('click', async () => {
  try {
    if (recording) {
      const r = await invoke<Recorded>('stop_recording');
      console.log(`${r.seconds.toFixed(1)} 秒を保存しました: ${r.path}`);
    } else {
      console.log('録音開始:', await invoke<string>('start_recording'));
    }
    recording = !recording;
    button.textContent = recording ? '停止' : '録音';
  } catch (e) {
    console.error(e); // 「入力デバイス(マイク)が見つかりません」など
  }
});

動作確認

npm run tauri dev で起動して録音ボタンを押し、話しかけるとメーターが動きます。停止を押すと、Windows ではコンソールに次のように出ます(識別子の部分は tauri.conf.json の identifier)。

録音開始: C:\Users\you\AppData\Roaming\com.example.app\recordings\rec-1757660000.wav
4.2 秒を保存しました: C:\Users\you\AppData\Roaming\com.example.app\recordings\rec-1757660000.wav

既定の設定はデバイスによって違い、たとえば USB カメラのマイクでは 48 kHz・2 チャンネル・f32 で届きます。できた WAV は OS 標準のプレーヤーで再生できます。

よくあるエラーと対処法

  • 「入力デバイス(マイク)が見つかりません」: 例のコードが返すメッセージです。マイクがつながっていないか、OS のプライバシー設定で止められています。
  • 録音がすぐ止まる・ファイルが空: ストリームをコマンドのローカル変数のままにすると、関数を抜けたときに drop されて止まります。State に入れて持ち続けます。
  • 長さ 0 秒の WAV が残る: finalize() されずに終わっています。停止のコマンドを通すか、例のように RunEvent::Exit で閉じます。強制終了では防げません。
  • 「no method named name found」で始まるエラー: cpal 0.18 で name() がなくなりました。表示名は device.to_string() か description() で取ります。古い例の &config も、0.18 では config を値で渡します。
  • macOS で許可ダイアログが出ない: Info.plist の NSMicrophoneUsageDescription を確かめます。一度拒否すると二度と出ないので、システム設定の「プライバシーとセキュリティ」→「マイク」で許可し直します。

OS ごとの違いと注意点

  • Windows: 設定の「プライバシーとセキュリティ」→「マイク」で、デスクトップアプリからのアクセスがオフだと録音できません。
  • macOS: Info.plist の説明文が必須で、署名するならエンタイトルメントも要ります(前提条件を参照)。
  • Linux: ビルドに ALSA の開発用ファイルが要ります。
  • WebView の getUserMedia: 許可の出方は OS(WebView)ごとに違い、Linux では既定のままでは使えない場合があります。どの OS でも同じ動きにしたいなら Rust 側で録ります。
  • ファイルの大きさ: WAV は 4 GB が上限で、48 kHz・2 チャンネル・16bit なら約 6 時間です。長時間の録音はファイルを分けます。
  • 録った音を別のスピーカーで鳴らすなら 音声の出力スピーカーを切り替える、MIDI キーボードの演奏は音声ではなく MIDI の信号として受け取れます(MIDI 機器と入出力する(midir))。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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