プログラミングのお題としてCrystalで簡単なbeepコマンドを作る

::: note info

これは [Crystal Advent Calendar 2023](https://qiita.com/advent-calendar/2023/crystal) の24日目の記事です。一つ前の記事は [Crystalの並列実行のやり方を調べた](https://qiita.com/kojix2/items/85ed72e34b7c07e86ab0) です。

:::

ある程度時間のかかる処理が完了したことを知りたい場合、たとえば OS の通知機能などを使う方法もありますが、音を出すという方法もあります。

視覚とは別の聴覚というチャネルを使って使って通知するメリットはいくつかあります。視線移動が不要どころか、PCの前に座って画面を見ている必要もなく、大きめの音が出るようにしておけば少し離れた場所で別の作業をしていても気付ける、音が出ると楽しい、などです。

（参考: [長い処理には通知音コマンドを仕込んでおくと捗るぞ](https://zenn.dev/magicmoment/articles/play-sound-command-20241205)）

Linux にはそんな用途に（も）使える beep というコマンドがあるのですが、これはPCスピーカーがないマシンだと使えません。そこで自作、というわけです。




# 最低限の例

私の Crystal の学習も兼ねて最低限のものを書いてみました。簡単なプログラムを書くことで Crystal 言語に慣れようという魂胆です。

```crystal
# 矩形波を生成する
# return: -1.0 <= x <= 1.0
def osc_square(t : Float, freq : Float) : Float
  # 1周期分の時間（秒）
  t_cycle = 1.0 / freq

  pos_cycle = (t % t_cycle) / t_cycle
  pos_cycle < 0.5 ? 1.0 : -1.0
end

def clip(v : Float) : Float
  if v < -1.0
    -1.0
  elsif v > 1.0
    1.0
  else
    v
  end
end

def write_u8(v : UInt8)
  slice = Bytes.new(1)
  slice[0] = v
  STDOUT.write slice
end

# --------------------------------

SAMPLING_RATE = 44100.0

freq     = ARGV[0].to_f # 周波数（Hz）
duration = ARGV[1].to_f # 長さ（秒）
amp      = ARGV[2].to_f # 振幅（音の大きさ）

(0...SAMPLING_RATE * duration).each do |i|
  t = i / SAMPLING_RATE
  v = osc_square(t, freq) * amp
  v_u8 = ((clip(v) + 1) / 2 * 255).to_u8
  write_u8(v_u8)
end
```

1サンプルごとに1バイト（UInt8）ずつ値を出力するだけの素朴な作りです。オシレータ（波形を生成する処理）は時間と周波数を入力とする関数という形にしています。効率は悪そうですが分かりやすさ優先で。

パパッと書いたナイーブな実装で、改善点はいろいろありそう……といっても自分で使う分にはこんなので十分なんですけどね。こんなので十分実用になります。

以下でビルドします。

```
crystal build beepgen.cr -o beepgen
```

beepgen という名前の通り、このプログラムが行うことはビープ音のデータを生成するだけです。生成した音を鳴らすにはどうするかというと、Linux だったら aplay コマンドに丸投げするのが手軽です。UNIX哲学に従って、上手に音を出してくれるコマンドにお任せしましょう。

次のようなシェルスクリプトでラップし、コマンドとして使えるようにしました。aplay は標準入力での受け渡しに対応しているのでパイプで渡せます。

```bash
#!/bin/bash

# "$@" を使って引数をそのまま beepgen に渡す
./beepgen "$@" \
  | aplay --rate=44100 --format=U8 --quiet
```

これでできあがり。このシェルスクリプトをコマンドとして使います。

```bash
# 周波数、長さ、振幅（音の大きさ）を指定して実行
./beep.sh 440 0.5 0.05
```

簡易に済ますためパラメータの指定方法はオリジナルの beep コマンドとは異なっています。



# 使い方の例

たとえば処理の終了ステータスを見て成功していたら高い音、失敗していたら低い音を出したいならこんな感じ。

```bash
do_something # ... 大きなプログラムのコンパイルなど、ある程度時間のかかる処理
exit_status=$?
if [ $exit_status -eq 0 ]; then
  # 成功したら高い音
  ./beep.sh 880 0.5 0.05
else
  # 失敗したら低い音
  ./beep.sh 220 0.5 0.05
fi
```

周波数や長さを変えて連続で実行することでバリエーションを作ることもできます。たとえば次のように連続で実行すると「ピポピポ」という感じの音になります。

```bash
./beep.sh 220 0.1 0.05
./beep.sh 440 0.1 0.05
./beep.sh 220 0.1 0.05
./beep.sh 440 0.1 0.05
```

次の例ではランダムな周波数を指定しています。昔のコンピュータの効果音（？）みたいな「ピパポポピポパピ……」という音が出ます。

```bash
for i in $(seq 10); do
  freq=$((100 + RANDOM % 1000))
  ./beep.sh $freq 0.1 0.05
done
```

# 改造案

思いついたものを挙げてみました。上の最低限の例では（一番簡単なので）矩形波を生成するようにしていますが、若干耳障りなのでサイン波、三角波くらいは対応したいですね。

- 矩形波以外の音も出せるようにする
  - サイン波、三角波、鋸波、ノイズ、FM音源、...
  - オプションで切り替え
- パラメータの指定方法をオリジナルの beep コマンドに寄せる
- WAV ファイルを出力する
  - 今回作ったものは生の波形データのみなので、ヘッダを追加してちゃんとした WAV ファイルを出力する
- 旋律を引数で指定できるようにする
  - `"440,0.1 880,0.2 110,0.1 220,0.2"` みたいな感じで指定できるようにする
  - `”c8 g8 d4 r8 a2 ...”` のように MML っぽく書けるようにする
- aplay コマンドを使わずに音を再生する

和音も出せるようにして……とかやりだすとちょっとしたソフトウェアシンセサイザーみたいになっていきそう。




# おわりに

音を扱うプログラミングは縁のない人には全く縁のない世界だと思いますが、割と簡単に
意外と気軽に




# バージョン

```
  $ crystal version
Crystal 1.10.1 [c6f3552f5] (2023-10-13)

LLVM: 15.0.7
Default target: x86_64-unknown-linux-gnu
```




# 参考

- [Ubuntu Manpage: beep - beep the pc speaker any number of ways](https://manpages.ubuntu.com/manpages/focal/man1/beep.1.html)
- [Ubuntu Manpage: arecord, aplay - command-line sound recorder and player for ALSA soundcard driver](https://manpages.ubuntu.com/manpages/focal/man1/aplay.1.html)

<!-- Crsytalで実行が終了したらnotifyするコマンドラインツールを作る #Crystal - Qiita -->
https://qiita.com/kojix2/items/5972178c46057fde0103





# この記事を読んだ人は（ひょっとしたら）こちらも読んでいます

<!-- プログラミングのお題としてCrystalで簡単なコマンドラインRSSリーダーを作る #Crystal - Qiita -->
https://qiita.com/sonota88/items/013bcbd3f8a12e2687db

<!-- 素朴な自作言語のコンパイラをCrystalに移植した #Crystal - Qiita -->
https://qiita.com/sonota88/items/275c2b5407986b100cc8