リモートワークにはマイク付きイヤホンを

年末年始からいくつかのヘッドセットを試しているのだけれども、結論として、ビデオ通話用のベースラインはマイク付きイヤホンだと再確認した。

  • 無線(Bluetooth HFP)イヤホンよりは通話品質も安定性も圧倒的に良いし、圧倒的なハズレもない
  • ノートPC内蔵スピーカー&マイクや、テレビ会議用スピーカーフォンに比べると、キーボードを叩く音や反響音が入りにくいし、他人の声も聞き取りやすい
  • 通話用ヘッドセットはノイズキャンセリング性能は優れているが、声が「通話用」っぽい声になる。イヤホンマイクの方が自然。
  • 配信用マイク付きのゲーミングヘッドセットや大きいマイクよりは手軽

単に聞き取りやすさだけを考えたら通話用ヘッドセットがいいのだが、YouTuberがみんないいマイクに投資しているのように、リモートワークでもより良い音で通話することで、信頼関係や親近感につながる可能性がある。GitLab Handbookでも、音質によってその人への信頼だったり話されていることの重要度の印象が改善されるという研究を紹介して、マイクに投資するように推奨していた。

なので、まずは全体的にバランスが良く移動にも便利なマイク付きイヤホンを選んで、さらに投資する場合に配信用のマイクかヘッドセットを選ぶといいだろう。

こちらの記事で、アシダ音響のヘッドセット MT-669CT vs Apple EarPods (3.5mm) vs Sony LinkBuds S の比較ができるのでぜひヘッドホンかイヤホンで聴き比べてみてほしい。

mentalhealthbiz.net

このヘッドセットは先日紹介したが、通話用ヘッドセットにしては珍しくマイクが良い、自然な音がするタイプだ。ほとんどの通話用ヘッドセットは声の聞き取りやすさにフォーカスしてもっと低音高音、残響がカットされた乾いた音になっている。

そして EarPods の音がそれにほとんど遜色なく、十分に聞き取りやすさと音の自然さを両立している。マイクは極小だけれどもある程度低音高音が入り、全指向性だから程よく残響も入って自然な音になっているのだろう。 全指向性といってもキーボードよりも口に近いからPCのマイクよりは騒音が入りにくく、しかも口の真ん前ではないので破裂音や唾液の不快音は拾わない。

一方でLinkBuds Sのマイク音質EarPodsよりもかなり悪く、特に騒音下ではひどいものだ。僕が試したJBLのLive Free2やSoundgear Senseも同じ傾向だった。

無線イヤホンの通話品質が厳しいのは、BluetoothHFPが使うmSBCというコーデックとビットレート不足のせいだ。ビットレート不足のJPEGモスキートノイズが乗るのと同じように、金属管を通したようなエコーが入る。音質を良くするにはエンコード前に声以外の情報を削るノイキャン性能が重要なのだが、省エネが求められるイヤホンでは難しく、ノイズを消しきれずノイズと声と金属エコーが混ざり合ったり、逆に声のボリュームに影響を与えてしまったりするのだろう。

どうしても無線イヤホンが使いたい場合、僕が試した中ではSoundPEATS Air4 Proは良かった。これはQualcommのチップを搭載しているのだが、長年通話用のチップを扱って来ただけあってノイキャンのアルゴリズム(cVc)が優れているのだろう。通話用には同じQualcommチップを使っていて(耳栓型ではなく)開放型の Air4 の方が良いかもしれない。どちらもSnapdragon Soundに対応しているので、スマホが対応していればmSBCよりも音がいいaptX Voiceが使える。

将来的にはLE Audioが無線イヤホンの問題を解決すると思っている。LE Audioを使っているINZONE Budsのレビュー動画を見ると、ノイズキャンセル性能は低い(レイテンシーを重視しているのだろう)ものの声を潰す事なく、特有のエコーもなく通話できている。エンコード前に綺麗にノイキャンできなくていいなら、Discord等の通話アプリのノイキャンに頼れるのだ。

話を元に戻すと、マイク付きイヤホンもいくつか試してみた。Zenfone 4とPixel 3に付属していたもの、あとApple EarPods 3.5mmとSonyのMDR-EX255APを買った。

スマホ付属イヤホンは通話品質には問題ない。付属イヤホンは通話も念頭に置いているので当たり前ではある。Pixel3付属のものはボリュームが小さかったが、これは通話ソフトが自動で音量調整してくれる範囲だ。

EarPodsは上の2つのイヤホンよりも帯域が広く、より自然な音で通話できた。第9世代iPadを使うこともあるので3.5mmを選んだけれども、Type-C接続でも同じ値段だ。耳栓型じゃないのも通話用にはメリットで、万人にお勧めできる。多くのセブンイレブン店舗でも買えるので、急に必要になった時に安心して買えるのも良い。

MDR-EX255APのマイクはEarPodsよりもさらにクリアな気がする。しかし、EarPodsと同じ価格帯のものを選んだのだけれど、イヤホンの音質が低音強めなのと耳栓型なので、通話用途にはEarPodsの方が適していると思う。 MDR-EX???APシリーズは上位機種も下位機種も全く同じマイクを使っているようなので、通話用と割り切るならEarPodsよりも低価格のEX15APかEX155AP、逆に音楽を高音質で聴きたいなら上のEX655APをお勧めする。

試してはいないけれども、他におすすめできそうなのがJVCのHA-FR17UCだ。EarPodsと同じく開放型で、手元でマイクミュートができて、値段はEarPodsよりも安い(約2500円)。同じマイクを使っている耳栓型のHA-FR9のレビュー動画を見つけたが、マイク音質には問題なかった。ちなみにFR17はUSB-C専用だけれど、FR9はUSB-Cと3.5mm両方があって、後者は特に安い(約1500円)。安くて手元ミュートがあると嬉しい場合はEarPodsよりもこちらが適しているだろう。

ryeをpyenvのように使う

最近は複数のPythonバージョンを用意するのにpyenvを使うのをやめてryeを使っています。

プロジェクトもryeで管理すればいいのですが、OSSメンテしていると良くあるのがIssueの再現のためにスクリプト1つ動かすための環境を作るケースでは若干ryeは面倒です。 src等のディレクトリは不要ですし、addしてからsyncしないといけないし、 python script.py じゃなくて rye runrye shell をしないといけないし。

なので、ryeでプロジェクト管理するよりも、ryeをpyenvのように使うことが多いです。しかも、pyenvを使っていた時はpythonの起動速度が遅くなる事を嫌ってshimsは使っていなかったのですが、ryeはオーバーヘッドがすごく小さいので、shimsにパスを通して rye pin を使って今のディレクトリで利用するPythonのバージョンを選択しています。

環境を一つ作ってライブラリをインストールするまでの手順を書いておきます。まず、普段から使っているエイリアスがこちらです。

# カレントディレクトリ配下に .venv というディレクトリ名で venv を作り有効化する
alias mkvenv='python3 -m venv --upgrade-deps --prompt . .venv && activate'

# すでに .venv がある場合にそれを有効化する
alias activate='source .venv/bin/activate'

また、 rye が管理するプロジェクト以外でもrye pinなどを使ってryeが管理するPythonを使えるようにするために、次の設定をしておきます。

rye config --set-bool behavior.global-python=true

このmkvenvエイリアスとryeを組み合わせて使い捨ての検証環境を作る手順です。

inada-n@macos:~
$ mkdir t && cd t

inada-n@macos:~/t
$ rye pin 3.11
pinned 3.11.7 in /Users/inada-n/t/.python-version

inada-n@macos:~/t
$ mkvenv  # ここでは rye の shims にある python が、 pin したバージョンのpythonを実行してくれる

(t) inada-n@macos:~/t
$ pip -V   # 仮想環境をactivate済みなので、pipもpythonも仮想環境のものを使える
pip 23.3.2 from /Users/inada-n/t/.venv/lib/python3.11/site-packages/pip (python 3.11)

(t) inada-n@macos:~/t
$ pip install msgpack
Collecting msgpack
  Using cached msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl.metadata (9.1 kB)
Using cached msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl (231 kB)
Installing collected packages: msgpack
Successfully installed msgpack-1.0.7

このように、pyproject.tomlを使ったプロジェクト管理をしない場合も、portable pythonをインストールして利用するためだけにでもryeを便利に使えます。

ちなみに上では mkvenv エイリアスを動かすために rye pin 3.11 しましたが、仮想環境を作ってしまった後はこのpinは不要になります。 エイリアスを使わないで直接コマンドを実行する場合は、pinの代わりに python +3.11 -m venv --prompt . .venv のように +3.X の形でバージョン指定できます。

アシダ音響 MT-669-CT と ST-90M-05-K

ふるさと納税でマイク音質が良さそうなヘッドセットを見つけたので購入してみた。1/22に寄付して1/26に届いたので、めちゃくちゃ早かった。

item.rakuten.co.jp

マイクの音質は、今まで色々買ってきた6千円以下のヘッドセットやイヤホンの中で一番いい。外の工事の音も入らないし、キーボードの音もかなり小さく抑制しているのに、声の音質に詰まった感じやこもった感じが全くなくて自然。

ちなみにアシダ音響はすごくコスパがいいと一時期話題になった軽量なヘッドホンを出していて、それにMT-669-CTと同じマイクをつけたヘッドセットも出しているので、買うならこちらの方がお勧めだ。ふるさと納税には(まだ?)出てきていないけれど、普通に買っても十分に安いし音楽も楽しめ、配信向けのいいマイクを搭載したゲーミングヘッドセットよりも軽いのでコスパは最強だと思う。

sql.Null[T] をGo 1.22に追加しました

Go 1.22 のリリースが近づいていますが、その中でdatabase/sqlNull[T]を追加したので紹介しておきます。

database/sql パッケージにはNullByte,NullBool,NullFloat64,NullInt64などのNullableなカラムを扱うための型が用意されているのですが、NullUInt64はありませんでした。 UInt型が標準的ではなく、driver.Valueにもuint64が含まれていないからです。

一方でMySQLはunsigned tinyint~bigint型があるのでgo-mysql-driverもuint64には対応しています。32bitまではint64で、64bitではuint64で扱うようになっています。 だからと言ってドライバー独自に NullUInt64 を提供すると、他のパッケージも同じ型を提供した時に混乱の元です。

ということで、ジェネリクスを使って sql.Null[T] を追加してもらうことにしました。

一番難しかったのはメンバーのネーミングです。例えば NullInt64 の定義は次のようになっています。

type NullInt64 struct {
    Int64 int64
    Valid bool // Valid is true if Int64 is not NULL
}

値が入っているメンバーの名前が、型の名前の先頭が大文字になったものになっているんですね。NullTime型の場合はTime time.Timeです。しかしこれに一貫性を持たせようとすると T T になるのでちょっと混乱します。

一番に思いつく名前はValueなのですが、これらの型はValuerインターフェイスを実装していて Value() メソッドを追加する必要があるので、Valueも使えません。

Valにすると、Val, Valid, Value() が並ぶことになってしまい、これもあまり良くありません。

最終的に名前は V T になりました。型名と値の名前が同じ1文字どうしなので Int64 int64 と同じくらいのペア感があり、 Val に比べると Valid, Value() との距離も少し離れたので、一番マシな選択肢だったと思います。

なお、データベースとのやりとりを目的として、Scanner/Valuerインターフェイスを実装するように設計した型なので、一般的な Optional 型の代わりに使おうとは思わないでくださいね。

RawBytesは使い捨てよう

go-mysql-driverに来たバグ報告を調べていたら、 database/sql.RawBytes の利用方法にハマるとデバッグの難しい落とし穴があったのを見つけたので、Go側のバグとは断言できないもののGo側で直すべきだと報告しました。他の人がハマらないように簡単に解説しておきます。

github.com

RawBytestype RawBytes []byteのように宣言されていて、[]byteのように扱える。[]byteとの違いは、利用可能期間が短くてrows.Scan(&in)からrows.Next()rows.Close()までの間にしか使えないという制約があることだ。

rows.Next()が呼ばれた時、ドライバーは受信バッファの中の文字列等のデータを浅い(shallow)コピーで返すことができる。そのデータは次にrows.Scan()が呼ばれた時に使われるのだが、Scanの引数が*[]byteなら新しいメモリをアロケートしてデータのコピーを行い、*RawBytesならドライバが返した[]byteをそのまま渡す。このデータはドライバの受信バッファ内に本体があるので、ドライバが次に何か動作をするときに消される可能性がある。そのため利用可能期間が短いのだ。

例えば、データベースに保存した大きいバイナリデータを取得してすぐにbase64エンコードする場合など、エンコード前のデータを一時的に持つためだけに大きいアロケートとコピーをしなくてよくなる。

この時、RawBytes型の変数を次のように宣言していれば、生存期間が次のrows.Next()後にまで引き継がれることがないので、他の場所に浅いコピーをしていない限り安全だ。

for rows.Next() {
    var data sql.RawBytes
    err = rows.Scan(&data)
    // data を使う処理
}

しかし、RawBytesのスコープを広くしてしまって、1つの変数を複数回rows.Scan()に渡してしまうと問題が起こるケースがある。上のバグ報告で書いたサンプルがこれだ。

var buf sql.RawBytes

rows := tx.Query(tx, "SELECT 'hoge'")
rows.Next()      // driver returns []byte("hoge") that references internal buffer.
rows.Scan(&buf)  // buf now points to driver's internal buffer.
rows.Close()

rows = tx.Query(tx, "SELECT 42")
rows.Next()      // driver returns int64(42).
rows.Scan(&buf)  // sql does `buf = strconv.AppendInt([]byte(buf)[:0], 42)`. That write "42" to the internal buffer.

1回目のScan(&buf)は結果が文字列なので、bufにはドライバの内部バッファの一部を指すような浅いコピーとして文字列が入っている。

しかし、2回目のScan(&buf)は結果が整数なので、ドライバはint64型で42を返している。そしてScan(&buf)はint64からRawBytesに変換する時に、buf = strconv.AppendInt(buf[:0], 42, 10)をしてしまう。なので前のScan時には"hoge"の"ho"の部分が格納されていた領域に、今は何が入っているか分からないのに、"42"を書き込んでしまうのだ。もしそれが受信バッファの中の次の行のデータだったら次の行が破壊されてしまう。

実際にはドライバの動作はドライバごとに異なるので上のはシンプル化したシナリオでしかない。実際にgo-mysql-driverに報告されていたIssueでは、たまたま次のパケットの長さを示す場所を別の数字で上書きしてしまい、来るはずのない大きいパケットを待ち続けていた。

RawBytesScan()に渡す時、ユーザーは *buf = 何かデータ のような代入先としての使い方しか想定しておらず、 *buf = append何か(*buf[:0], 何か) のような追記先としての使われ方は想定外だろう。Appendを使うことでアロケートを避けられるケースもあるかもしれないが、それはごく稀だ。今回のように止まるとか、エラーが返るならまだマシで、サイレントにデータを壊す可能性もあるので、Scan()はRawBytesへ追記すべきではないと思う。

しかし、ずっと前から現バージョンまでずっとこの動作になっていた以上、ユーザーはなるべくRawBytesを避け、使う場合もスコープに気をつけて使わなければいけない。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。