WirePlumber(PipeWire)の音声出力先切り替えスクリプトを書いた

単純なAudio Sinkの切り替えアプリを作ったが

でも最初から dmenu(かrofi)を使う前提でスクリプト書いたほうが、ずっと素直だった。

と思ったので、スクリプトXMonadから実行するように変更した。スクリーンショットを取ってみたけど、知らない人が見たら何だかわからないなこれ。

dmenuでAudio Sinkの切り替え
オリジナル画像リンク(113.66 KiB)


switch_audio_sink.sh

#!/bin/bash

declare -A devices
while IFS=$'\t' read -r id name; do
   devices["$name"]="$id"
done < <(wpctl status | awk '
  /Sinks:/ {found=1; next}
  /Sources:/ {found=0}
  found && /^\s*│/ {
    gsub(/^[ \t│*]+/, "")
    if (match($0, /^([0-9]+)\.\s+([^\[]+)/, arr)) {
      id = arr[1]
      name = arr[2]
      sub(/ *$/, "", name)
      print id "\t" name
    }
  }
')
count=${#devices[@]}

if [ $count -eq 0 ]; then
  notify-send "Audio Device Selector" "No audio device found" -u critical
  exit 1
fi

name_list=$(printf "%s\n" "${!devices[@]}")

selected=$(echo "$name_list" | dmenu -l $count -p "Select Audio sink:")
if [ -z "$selected" ]; then
  exit 0
fi

selected_id=${devices["$selected"]}
wpctl set-default $selected_id

RustでGUIアプリを試しに作ってみた

Windows11のCTRL+WIN+vで表示されるAudio Sinkの切り替えショートカットがLinux(XMonad)で欲しくなり、探したけど丁度いいものが見つからなかった。
チープに作ればこんなの簡単に作れるはずだと考え、Rustで作ってみることにした。GUIツールキットで何を使うかが問題で、歴史が浅いために決定的に良いものが存在しないらしい。結果として、サンプルアプリを作りながら右往左往することになった。

最初の試作

まず、tray-iconで試作した。タスクバーにアイコンを表示して、そこからメニューも出せる。ここまでは順調だったけど、ラジオボタンの表示がどうしてもできない。この時点で「今回の用途には合わないな」と判断してボツにしたけど、今思えばこれで十分だったかもしれない。

2回目の試作

次は libappindicator と gtk3 の組み合わせで試作した。アイコンもメニューも出せるところまではいったんだけど、gtk-rs(gtk3バインディング)が、今後のメンテが怪しいと知って破棄することになった。

3回目の試作

じゃぁ gtk4-rs を使おうとしたら、libappindicator との連携が効かない。タスクバーにアイコンを置くことを諦めて、iced を使うことにした。
ラジオボタン付きのメニュー表示まで作ったけど、キーボードでの操作ができない。icedはまだマウス前提のUIしか用意されてないっぽい。自分の目的には合わないのでこれも破棄。

4回目の試作

最終的に gtk4-rs を使うことになった。ただこれ、ラジオボタンすら自前実装が必要で、やる気が削がれた。ListBox で項目を並べて、キーボードで選べるだけの超シンプルな構成にした。

で、このアプリをXMonadの Ctrl + Win + v に割り当てたことで、やっと目的は達成。キーボードだけでオーディオ出力先を切り替えられるようになった。

でも最初から dmenu(かrofi)を使う前提でスクリプト書いたほうが、ずっと素直だった。

Audio Sink選択アプリの使用風景
オリジナル画像リンク(189.96 KiB)

miniaudioでdecoding APIを使わないサンプル

マルチプラットフォームでpcm再生できるライブラリを探した結果、miniaudioを使うことにした。ドキュメントにはdecoding APIを使うサンプルがあるので、それを使えば簡単に使うことが出来る。

で、本来の目的の方でチョット問題があったので、decoding APIを使わない実装にすることを考えて、サンプルコードを書いた。https://x68kace.hatenadiary.org/entry/2025/04/05/160252に書いた loadWavFile() も使っている。

#define MINIAUDIO_IMPLEMENTATION
#define MA_NO_ENCODING
#define MA_NO_DECODING
#define MA_NO_GENERATION
#define MA_NO_RESOURCE_MANAGER
#define MA_NO_NODE_GRAPH
#define MA_NO_ENGINE
// #define MA_NO_DEVICE_IO
// #define MA_NO_THREADING
#define MA_NO_STDIO
#define MA_NO_WAV
#define MA_NO_FLAC
#define MA_NO_MP3

#include <condition_variable>
#include <filesystem>
#include <iostream>
#include <mutex>

#include "load_wave.h"
#include "miniaudio.h"

typedef struct {
  const void *pcmData;
  size_t pcmFrames;        // フレーム単位でサイズを保持
  size_t currentFrame;     // フレーム単位で現在位置を保持
  ma_uint32 bytesPerFrame; // フレーム単位のbyte数
  std::mutex mtx;
  std::condition_variable cv;
  bool running;
} UserData;

// とりあえず、pcmであることを前提に、ma_formatを得る
ma_format get_ma_format(uint16_t bitsPerSample) {
  switch (bitsPerSample) {
  case 8:
    return ma_format_u8;
  case 16:
    return ma_format_s16;
  case 24:
    return ma_format_s24;
  case 32:
    return ma_format_s32;
  default:
    return ma_format_unknown;
  }
}

void stop_callback(ma_device *pDevice) {
  (void)pDevice;
  std::cout << "stop_callback called" << std::endl;
}

void data_callback(ma_device *pDevice, void *pOutput, const void *pInput,
                   ma_uint32 frameCount) {
  (void)pInput;
  UserData *userData = static_cast<UserData *>(pDevice->pUserData);
  size_t remainingFrames = userData->pcmFrames - userData->currentFrame;
  size_t framesToCopy = std::min<size_t>(remainingFrames, frameCount);

  if (framesToCopy > 0) {
    size_t bytesToCopy = framesToCopy * userData->bytesPerFrame;
    memcpy(pOutput,
           static_cast<const uint8_t *>(userData->pcmData) +
               userData->currentFrame * userData->bytesPerFrame,
           bytesToCopy);
    userData->currentFrame += framesToCopy;
  } else {
    // currentFrameがpcmFramesを超えることがない
    // つまり、コピーするデータがなければ最後までデータを渡した
    {
      std::lock_guard<std::mutex> lock(userData->mtx);
      userData->running = false;
    }
    userData->cv.notify_one();
  }
}

int main(int argc, char *argv[]) {
  if (argc == 1) {
    std::cerr << "No Wave file" << std::endl;
    return 0;
  }
  std::filesystem::path abs_path = std::filesystem::absolute(argv[1]);
  std::string path_string = abs_path.string();
  WavData wav_data;
  if (LoadWavResult::Success != loadWavFile(path_string, wav_data)) {
    std::cerr << "loadWavFile failed" << std::endl;
    return -1;
  }

  ma_context context; // The configuration of the audio device remains the same
  ma_device_config deviceConfig;
  ma_device device;
  UserData userData;

  if (ma_context_init(nullptr, 0, nullptr, &context) != MA_SUCCESS) {
    std::cerr << "Failed to initialize context" << std::endl;
    return -1;
  }

  deviceConfig = ma_device_config_init(ma_device_type_playback);
  deviceConfig.playback.format = get_ma_format(wav_data.header.bitsPerSample);
  deviceConfig.playback.channels = wav_data.header.numChannels;
  deviceConfig.sampleRate = wav_data.header.sampleRate;
  deviceConfig.dataCallback = data_callback;
  deviceConfig.pUserData = &userData;
  deviceConfig.stopCallback = stop_callback; // 本当は不要

  if (ma_device_init(&context, &deviceConfig, &device) != MA_SUCCESS) {
    std::cerr << "Failed to initialize playback device" << std::endl;
    ma_context_uninit(&context);
    return -1;
  }
  // 初期化時に一度だけ呼び出して取得する
  ma_uint32 bytesPerSample = ma_get_bytes_per_sample(device.playback.format);
  if (bytesPerSample == 0) {
    std::cerr << "Invalid format" << std::endl;
    ma_device_uninit(&device);
    ma_context_uninit(&context);
    return -1;
  }

  userData.pcmData = wav_data.pcmData.data();
  userData.bytesPerFrame = bytesPerSample * deviceConfig.playback.channels;
  userData.pcmFrames = wav_data.dataSize / userData.bytesPerFrame;

  userData.currentFrame = 0;
  userData.running = true; // 再生中とする

  ma_device_start(&device);
  std::cout << "音声再生開始" << std::endl;
  {
    std::unique_lock<std::mutex> lock(userData.mtx);
    // ありえないが、先に通知が来た場合に対応
    while (userData.running) {
      userData.cv.wait(lock);
    }
  }
  std::cout << "音声再生終了、デバイス停止" << std::endl;
  ma_device_stop(&device); // data_callback内で停止

  ma_device_uninit(&device);
  ma_context_uninit(&context);

  return 0;
}

wavファイルの読み込み処理をいまさら書いた

ある処理に問題があって、それを直すために、2025年にもなってwavファイルの読み込みコードを書いた。PCM以外のwavファイルを扱うのは確認が面倒だったので、非対応として弾くことにした。エラーチェックはそこそこ入れたけど、多分バグがある気がする。

C++で実装したのは、問題のコードがC++で書かれていたから。 std::filesystem::absolute を使うために C++17 のコードになっている。あと、C言語からでも呼びたくなったときに修正しやすくするため、クラスにはしなかった。

wavファイル読み込み関数

#include "load_wave.h"

#include <cstring>
#include <fstream>

LoadWavResult loadWavFile(const std::string &filename, WavData &wav) {
  std::ifstream file(filename, std::ios::binary);
  if (!file) {
    return LoadWavResult::FailedToOpen;
  }

  file.read(reinterpret_cast<char *>(&wav.header), sizeof(WavHeader));
  if (file.eof()) {
    return LoadWavResult::InvalidFileSize;
  }

  // Check RIFF Chunks
  if (std::strncmp(wav.header.riff, "RIFF", 4) != 0) {
    return LoadWavResult::InvalidRiffChunk;
  }
  if (std::strncmp(wav.header.wave, "WAVE", 4) != 0) {
    return LoadWavResult::InvalidWaveFormat;
  }
  if (std::strncmp(wav.header.fmt, "fmt ", 4) != 0) {
    return LoadWavResult::InvalidFmtChunk;
  }

  // Currently only PCM support
  if (wav.header.audioFormat != 1) {
    return LoadWavResult::UnsupportedAudioFormat;
  }

  if (wav.header.fmtSize < 16 || wav.header.fmtSize % 2 != 0) {
    return LoadWavResult::InvalidFmtSize;
  }
  if (wav.header.fmtSize > 16) {
    file.seekg(wav.header.fmtSize - 16, std::ios::cur); // Skip variable part
  }

  // Find data chunks
  char chunkId[4];
  uint32_t chunkSize;
  bool found = false;

  while (file.read(chunkId, 4)) {
    if (file.eof()) {
      return LoadWavResult::UnexpectedEndOfFile;
    }
    file.read(reinterpret_cast<char *>(&chunkSize), 4);
    if (file.eof()) {
      return LoadWavResult::UnexpectedEndOfFile;
    }

    if (std::memcmp(chunkId, "data", 4) == 0) {
      wav.dataSize = chunkSize;
      found = true;
      break;
    }

    // Skip to the next chunk
    file.seekg(chunkSize, std::ios::cur);
  }

  if (!found) {
    return LoadWavResult::NoDataChunk;
  }

  wav.pcmData.resize(wav.dataSize);
  file.read(reinterpret_cast<char *>(wav.pcmData.data()), wav.dataSize);
  if (file.eof()) {
    return LoadWavResult::InvalidDataSize;
  }

  return LoadWavResult::Success;
}
#endif

外部公開用のヘッダファイル

#include <cstdint>
#include <string>
#include <vector>

#pragma pack(push, 1)
struct WavHeader {
  char riff[4];           // "RIFF"
  uint32_t fileSize;      // File Size
  char wave[4];           // "WAVE"
  char fmt[4];            // "fmt "
  uint32_t fmtSize;       // Format chunk size
  uint16_t audioFormat;   // Audio format (1 = PCM)
  uint16_t numChannels;   // Number of channels
  uint32_t sampleRate;    // Sample Rate
  uint32_t byteRate;      // Byte Rate
  uint16_t blockAlign;    // Block Arrival
  uint16_t bitsPerSample; // Bit Depth
  // Data chunks and PCM data sizes vary
};
#pragma pack(pop)

struct WavData {
  WavHeader header;
  uint32_t dataSize; // PCM data size
  std::vector<uint8_t> pcmData;
};

enum class LoadWavResult {
  Success = 0,
  FailedToOpen,
  InvalidFileSize,
  InvalidDataSize,
  InvalidRiffChunk,
  InvalidWaveFormat,
  InvalidFmtChunk,
  UnsupportedAudioFormat,
  InvalidFmtSize,
  NoDataChunk,
  UnexpectedEndOfFile,
};

LoadWavResult loadWavFile(const std::string &filename, WavData &wav);

作った関数の動作確認(pcm再生は別)

#include <filesystem>
#include <iostream>

// Function to display the contents of a WavHeader
void printWavHeader(const WavHeader &header) {
  std::cout << "========== WAV Header ==========" << std::endl;
  std::cout << "RIFF Header       : " << std::string(header.riff, 4) << std::endl;
  std::cout << "File Size        : " << header.fileSize + 8 << " bytes" << std::endl;
  std::cout << "WAVE Header      : " << std::string(header.wave, 4) << std::endl;;
  std::cout << "FMT Header       : " << std::string(header.fmt, 4) << std::endl;;
  std::cout << "FMT Chunk Size   : " << header.fmtSize << " bytes" << std::endl;
  std::cout << "Audio Format     : " << header.audioFormat << " (1 = PCM)" << std::endl;
  std::cout << "Channels        : " << header.numChannels << std::endl;
  std::cout << "Sample Rate     : " << header.sampleRate << " Hz" << std::endl;
  std::cout << "Byte Rate       : " << header.byteRate << " bytes/sec" << std::endl;
  std::cout << "Block Align     : " << header.blockAlign << " bytes" << std::endl;
  std::cout << "Bits Per Sample : " << header.bitsPerSample << " bits" << std::endl;
  std::cout << "================================" << std::endl;
}

int main(int argc, char *argv[]) {
  if (argc > 1) {
    std::filesystem::path abs_path = std::filesystem::absolute(argv[1]);
    std::string path_string = abs_path.string();
    std::cout << path_string << std::endl;
    WavData wav_data;
    loadWavFile(path_string, wav_data);
    printWavHeader(wav_data.header);
    std::cout << "Data Size : " << wav_data.dataSize << " bytes" << std::endl;
  } else {
    std::cout << "No file" << std::endl;
  }
  return 0;
}

US配列キーボード購入

約1年、JP109キーボードにUS配列を設定して使い続けたので、「そろそろ本格的にUS配列キーボードを使ってみよう」と思い、約25年ぶりに新たなUS配列キーボードを導入した。

んで、今回購入したのは Realforce R3S Keyboard / R3SD13 です。
US配列、テンキーレス、30g荷重という条件を選んだ時点で1つに決まってしまった。R3 Keyboardは、Bluetooth接続をほぼ使わないので、追加の1万円の出費を避けることになった。

Realforce 89U以来の2台目Realforceなんだけど、まず最初にディップスイッチがないんだと驚いた。よくわからなかったのでRealfoceのサイトのマニュアルを読んでREALFORCE CONNECTをインストールしてファームウェアも更新した。

4年ぶりのRealforceに触れた感想としては、打鍵感がかなり軽い(スコスコ)と感じたが、すぐに慣れた。久しぶりに本物のUS配列なので、キー配置にも少し時間が必要だった。

日本語入力の基本方針

US配列で日本語入力する際は、左右のALTキーを単独で押した場合のみ変換(右ALT)・無変換(左ALT)キーとして機能させる方法を採用した。

Windows11のUS配列対応

キーボード設定を英語キーボード(101/102キー) に変更して再起動した後、
取り急ぎalt-ime-ahkを導入した。
普段コーディングを行っていないので、この設定で特に不自由は感じない。

Linux(X11)のUS配列対応

Waylandには移行していないので、X11向けの設定です。もともとキーボード設定をau(PC105)にしていたので、 xremap の設定変更だけで完了した。

modmap:
  - name: SandS
    remap:
      Space:
        held: Shift_L
        alone: Space
        alone_timeout_millis: 400
    application:
      not : [URxvt,/steam/]
  - name: AltIME
    remap:
      KEY_LEFTALT:
        held: LEFTALT
        alone: KEY_MUHENKAN
        alone_timeout_millis: 400
      KEY_RIGHTALT:
        held: RIGHTALT
        alone: KEY_HENKAN
        alone_timeout_millis: 400

keymap:
  - name: Huge Track Ball
# Left button:    BTN_LEFT
# Right button:   BTN_RIGHT
# Tilt Wheel:     BTN_MIDDLE
# Tilt left:      unknown key 59987/59999
# Tilt Right:     unknown key 59986/59998
# Forward button: BTN_EXTRA
# Back button:    BTN_SIDE
# Fn1:            BTN_FORWARD
# Fn2:            BTN_BACK
# Fn3:            BTN_TASK
    remap:
      BTN_TASK: CTRL-W
    device:
      only: ['ELECOM TrackBall Mouse HUGE TrackBall']
  - name: Keyboard Volume Control
    remap:
      KEY_VOLUMEUP:
        launch: ["wpctl", "set-volume", "-l", "1.0", "@DEFAULT_AUDIO_SINK@", "0.1+"]
      KEY_VOLUMEDOWN:
        launch: ["wpctl", "set-volume", "-l", "1.0", "@DEFAULT_AUDIO_SINK@", "0.1-"]
      KEY_MUTE:
        launch: ["wpctl","set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]
  - name: Caps2BS
    remap:
      CapsLock: KEY_BACKSPACE
      Shift-CapsLock: CapsLock

DvorakY改の見直し

日本語入力では、Mozcのローマ字テーブルをDvorakY改に設定しているため、特に見直しは不要だった。

ネット上ではDvorakYを常用している人はほとんどおらず、DvorakJPがデファクトスタンダードに近い状況です。DvorakYについて言及があるのが、https://ikatakos.com/pot/keyboard_layout/miscellaneous/20130128ぐらいしか残っておらずさみしい限り。個人的にはDvorakJPは極端すぎるので、そこまでするなら「かな入力」の練習をしたほうがいいと感じてる。

DvorakY改は上記サイトに記載されている欠点を持つこと対して、下記の変更を行っている。

パ行
(Dvorak配列の)「P」ではなく「L」に割り当て(QWERTYの「P」と位置が一致)
小書き文字(ゃゅょぁぃぅぇゎ)
「L」をパ行に使ってしまったので、「\」に割り当て(※使用頻度が低いため)
「'」キー
(Dvorak配列の)「'」に「ん」を割り当て(AZIKの影響)
「P」キー
(Dvorak配列の)「P」に「っ」を割り当て(AZIKの影響)
DvorakY改:US配列用
オリジナル画像リンク(53.62 KiB)