ラベル dev の投稿を表示しています。 すべての投稿を表示
ラベル dev の投稿を表示しています。 すべての投稿を表示

2025年9月7日日曜日

謎のエフェクトライブラリ emoji-particle を作った

謎のエフェクトライブラリ emoji-particle を作りました。 イメージ図のように、絵文字で花火を打ち上げたり、ポップコーンっぽいエフェクトを表示できるライブラリです。 柔軟なエフェクト設定ができるため様々な用途に応用できます。


Demo でおおまかな動作を確認できますし、 marmooo/emoji-particle から MIT ライセンスでコードを利用できます。

fontconv

前からずっと似たようなものは欲しかったのですが、 真面目に実装すると大変で放置していたのですが、 既存アプリが増えてきていよいよ欲しくなってきたので作りました。 Web Worker + OffscreenCanvas + 効率的な更新処理で実装しているので、 無駄に高速・高機能なライブラリとなっています。 内部の実装もかなり色々な最適化をしているので、大量に絵文字を飛ばしても重いと感じることはほぼないでしょう。

Worker を使ったライブラリを何も考えずに公開すると同一ドメインの制約があって使いにくいので、 インライン化することで簡単に使えるようにしています。 import { createWorker } from "emoji-particle"; するだけで使えるようになっています。 Worker 系のライブラリだとあるあるなんでしょうが、ビルドはみんなどうしてるんでしょう。 私はもちろん esbuild でオレオレビルドです。

こういったエフェクトは、良いものを作ろうとすると画像が必要ですが、 たくさん画像を使い始めると取り扱いが急に面倒になります。 RPG ツクールのホコグラっぽく管理すれば多少は…とは思うのですが、そういうことを考えるのも嫌です。 画像を扱い出すとと、ファイルサイズも大きくなるし、画像ファイルの管理も面倒だし、 ライブラリのロードも面倒になります。 絵文字はどんな環境でも使えるカラフルな画像集なので、 このようなときに最も扱いやすいです。

シンプルな割にはあらゆる場面で使えて便利なライブラリだと思っています。 これまで作ったアプリたちにもこれから組み込んでいきます。 それっぽいエフェクトを付けたい時には最も重宝するライブラリになりそう。

2025年8月26日火曜日

頻出順の英和辞書 mGSL を更新した

頻出順の英和辞書 mGSL を更新しました。 以前開発した mGSL は頻出順データ自体は最強でしたが、 既存の辞書を利用しているため和訳の粒度にバラツキがあり、英和辞書としては不安定でした。

mGSL

そこで生成 AI を活用して簡易的な和訳を作り、最強の和英辞書に作り変えました。 生成 AI を使って詳細な辞書を作るのはかなり苦しいと思いますが、 英単語学習では典型的な意味表現のみを知っていれば十分です。 なるべく覚える言葉自体が少なくなるように和訳の粒度を調整した辞書として mGSL を更新しました。 このような用途では生成 AI による文章生成が極めて有効です。 とはいえ既存の辞書データでさえ 3万語あったので、すぐにはできません。 AI の生成は頻繁に壊れるので完全には自動化できないため、 サボれる時間を見つけてちまちまと文章生成を繰り返すことで構築しました。

まずは既存の辞書に登録されていた 3万語まで和訳を付けました。 しかし既存の辞書のデータは頻度データをきちんと考慮していないので、 実際には頻度7000語くらいまでしか安定して登録されていません。 それ以降は歯抜けが多いので、ちまちまと翻訳を作っていく必要があります。 lemmatization されたデータは 6万件あります。 厳密な頻度で 3万語に到達する頃には、3万5000語くらいになってそうです。 これはさすがに果てしない…ということで、頻度 1万語までは完璧なものにしてこの記事を書きました。 それ以上はニーズの低さからやる気があまり出ないですが、ぼちぼちやりたいところ。 1万語あれば海外の大学生くらいの語彙数になるので、たいていのニーズは満たせるでしょう…。

生成された訳は目視で気になる点はチェックしており、 機械的に処理できるようにフォーマットを整えたりしています。 訳が不安定なところも微修正しています。 ドイツ語やイタリア語など、英語以外の語彙も含まれているので、英語学習には不向きなものも多々あります。 これらは機械的に除外できるようにしました。 AI さんに頼んでもすぐ崩壊するのでこのへんは手動で直すしかありません。 手動は手間ですが、和訳を作る部分が一番時間が掛かるので、90% は時間を削減できているでしょう。 それでも結構な時間は掛かりました。単調すぎて眠いのが地味に厳しかった。 ただその甲斐もあってか、以前は見つからなかったアラも多少修正できました。 依存ライブラリをがっつり減らすことができて、だいぶ気楽になりました。

英単語学習の辞書データとしては、より詳細なものを作る以外だと、 これ以上のものを作るのは難しいんじゃないかな。 あるとしたら lemmatization をちょっと改良できるくらい。これはいずれ検証したい。 あとはいよいよ接尾辞や現在分詞、過去分詞をもっと考慮して語彙数を減らしていくほうが、辞書としては質が高くなるのかもなあ。

Vocabee など既存の英単語アプリ、 graded-enja-corpus などの派生ライブラリには反映済みですが、 新規アプリも今後作っていきます。 やはり和訳の粒度を調整できているのはあまりにも大きい。 以前と変わらず CC-BY-SA で使えるので、使いたい人はどうぞ。

2025年2月11日火曜日

MIDI 再生ライブラリ Midy を作った

MIDI 再生ライブラリを前々から欲しかったので、Midy というライブラリを作りました。 ひとまず GM1 の再生に必要な機能は実装しているつもりです。 このライブラリを作る前は FluidSynth を wasm にするのが無難ではあったと思いますが、 wasm サイズが大き過ぎたり、確認が甘いだけかも知れませんが動作に納得がいかないところがありました。 うまく使いこなせなかったので、再生負荷が低く、ライブラリのサイズが小さく、拡張性の高い実装が欲しかったです。 Web での利用を想定して SF3 形式に対応していることは大前提です。

fontconv

構想段階の話

上記の条件を満たすライブラリの開発は前々から検討していて、 最初はサウンドフォントのパーサを実装してサウンドフォントを読み込み、 @tonejs/midi で MIDI をパースし、Tone.js で再生処理を実装することを考えていました。 Tone.js を使うとコントロールチェンジの実装が簡単かなと最初は思っていました。

実際にこの構成で基本的な部分はあっさり完成しました。 しかしメインスレッドで実行すると再生負荷が高い問題にすぐ直面しました。 何も考えずにスケジューリングするだけではすぐにラグが発生します。 再生負荷が高いのは短時間にイベント処理を大量に行うからと予想できたので、 setInterval() を使って対象となる音符を探しながら、 AudioWorklet 上でサンプリング単位で処理を行うことで、負荷を低減する実装をしました。 まあまあ動くようになったところで他のライブラリを改めて見てみると、 AudioWorklet を使う実装はほとんどないことを知りました。 AudioWorklet を使う方法もアリとは思いますが、 たいていは AudioBufferSourceNode で十分のようです。

AudioBufferSourceNode を使った MIDI 再生は以下が参考にはなるのですが、 GUI の実装と基盤ライブラリの実装が絡み合っていたり、バグがあったり、中身がよくわからないところが多いので、やはり自作することにしました。 あとゼロから実装していると「その実装じゃ動かないんだわ」にたくさん気付かされたので、やはり採用はできなかったです。 でもまあ私の実装もまだまだ間違いがたくさんあると思うので、じょじょに直していきます。 私が欲しいのはきちんとした再生ができるライブラリです。 他にも色々な実装や fork がありますが、中身がよくわからないところが多いので参考にしてないです。 ちなみに再生負荷を下げるだけなら js-synthesizer が安定しています。 自作はより良いものを作るためです。 他には FluidSynth や Timidity のコードは当然ながら参考にはなるのでしょうが、苦手過ぎてあまり読めていません。 やっぱ JavaScript のコードってめちゃくちゃ読みやすいし、メンテしやすいんだよなあ。

前準備 (@marmooo/soundfont-parser を作った)

きちんとした再生ライブラリを作るためには色々前準備も必要でした。 まず @tonejs/midi だと SysEx 命令に対応できないことに気付いたので midi-file を使って MIDI ファイルをパースするようにしました。 サウンドフォントのパーサは ryohey/sf2synth.js のコードが綺麗だったのでベースとしながら、SF3 サポートを加えました。 名前はせっかくなので @marmooo/soundfont-parser にしました。 SF3 サポート以外にも、実装途中で気付いた問題として、パーカッション対応や、軽微な処理速度の向上、規格準拠率の向上、strict mode への対応などもしています。

サウンドフォントで一番謎だったのは音源のサンプリングデータの扱いです。 SoundFont 2.04 の 24bit とかどうやって扱うんでしょうかねこれは…。 そんな型はないので 32bit にするのか、専用の型を用意するのか。 サンプリングデータは通常は 16bit で扱うんだと思いますが、 SF3 サポートや 24bit 対応を考えると Uint8Array が正解かな。 ただこれだとサウンドフォントの仕様を知ってないと扱えなくなる欠点はあります。 とはいえどうせ以下のサウンドフォントの仕様をよく読んで作らないと動かないので、あまり差はない気もします。 しかし読んでもわからないことのほうが多いのがまた問題ですが…。

再生処理の実装

SF3 形式が読み込めるようになった後は、再生処理を作りました。 最初は setInterval() で作っていたのですが、処理が被ると酷いことになることがわかったので、setTimeout() にしました。 しかし setTimeout() もバックグラウンド再生の課題があるとわかったので AudioBufferSourceNode を使ったタイマーを作っています。 このへんの話は、JavaScript で使えるタイマーのベンチマークを作った が関連します。

タイマー周りは検証が面倒臭かったですが、実装面で苦労したのは Promse 周りです。 Promise は本当に難しくて、たとえば以下の 1行だけでバグります。
const promise = new Promise(...);
return promise;
これは以下のように書かないといけません。
return new Promise(...);
音声処理みたいにタイミングがシビアだと、非同期でない部分をすべて Promise で囲わないとバグります。 1ms くらいのタイムラグの話ですが、音声処理だとこれが致命的な問題になります。 他のアプリで山のように間違ってそうだから直していかないとなあ。

何日か格闘して再生処理をフルスクラッチで書いたら、JavaScript でも十分早いことがわかりました。 途中で気付きましたが、音声処理は大部分がブラウザ上でネイティブ動作するので、 wasm にしたからといって速度が上がる訳ではないと思います。 速度が上がるのはせいぜいバッファー生成だけですが、MIDI では事前生成がほとんどです。 再生はネイティブのタイマーで処理できるので、 JavaScript 側では負荷の小さいスケジューリングで渡せば十分と思います。 より高速な実装を目指す場合、AudioWorklet + wasm でバッファー処理すれば、 リアルタイム性では勝てる可能性があるかも知れませんが、wasm よりネイティブのほうが早いので期待はできないと思います。

ちなみに Tone.js のコードもかなり眺めたのですが、私の実装と基本的には一緒だと思います。 ただ核心部分では演奏位置のメモリ管理、タイムラインの検索処理、 タイムラインのループ処理あたりの実装が非効率です。 エフェクトとかもチェインを細かく設定しないといけないので、 なんやかんや自作が最強な気がしています。

MIDI Event の実装

根幹部分が完成したらあとはひたすら細かな実装です。 コントロールチェンジや SysEx 命令などの MIDI イベントを地道に実装します。 実装しなければいけないことを、以下で確認しながら作りました。 ただ非常にドキュメントが長く真面目に読んでいるといつまで経っても実装が終わらないため、正直あまりきちんとは読んでない気がします…。 RPN/NRPN 命令は以下も参考にしました。検索しやすい公式情報もほとんどないので、かなり困る。 SysEx 命令は以下も参考にしました。 ただ結局のところ、Web の情報も当てにはならないので、以下の公式情報が参考になるかと思います。 GM1 と GMLite は大差ないですが、GM1 は CC#120 を記載していないように、若干内容が古い印象を受けます。 GM1 サポートをする場合でも CC#120 などは最低限実装したほうが良さそうです。 GMLite の規格書はボリューム周りで致命的すぎる齟齬があったりして非常に困りました。GM2 を読んだほうが良いです。 GM1 の規格書は長すぎるのでまったく読んでいないですが、将来的には読まないといけなそう。 GM1 に対応できて高速・低負荷で再生できれば、シンセサイザーとして 3流には到達と思います。 そこまで実装すれば基本的な実装が正しいことがわかるしコードも安定するはずと思い、まずはそこまで実装して公開しました。 既に基本的な再生は問題なくできるはずで、いまは色々な楽曲を聴きながら細かな調整を加えている段階です。 特にエフェクトは実装が正しいかよくわかっていないので、リバーブやコーラス、ペダルなどの検証が必要です。 あとは細かい最適化や、初期値の設定、細かな追加機能、ベンチマーク、テスト、エラー処理を加えていくのが今後の予定です。

TODO

先に述べたエフェクトは GM2 の話で、既に結構実装はできているのですが、完璧ではない状態です。 GM2 をフルサポートをしようとすると、Global Parameter Control でリバーブやコーラスの推奨要件が細かく規定されているので、実装の精度が上がっていくと思います。 たぶん一番よくわからなかったコーラス周りの仕様が安定するはずです。

現状の課題としてはリバーブが重いことを認識しています。 複雑な実装はしていないので、単純にリアルタイム処理が重いということだと思います。 MIDI はリアルタイム処理すると、構想段階の実装でもノート再生でさえ遅いことがわかっているので、いかにリアルタイム処理をなくすかが肝となりました。 リバーブの重さも、回避するにはなるべく様々な処理をオフラインでレンダリングしておいて、リアルタイム処理を減らす必要があると思います。 いかにリアルタイム処理をなくすかが肝だからこそ、AudioWorklet を使うのはあまり良い手段ではなさそう。 MIDI はノート・チャネル・マスターと 3層に分かれて音声処理をしていているので、層ごとに最適化が必要そうです。 GM2 だと初期状態でリバーブが掛かっていることを想定しているように見える のですが、これは現状だとちょっと厳しい。 今のところ GM1 対応しかしてないので、リバーブはオフにしています。 リバーブがなくても大量に音符があると稀に重いので、最適化はまだまだ必要です。

あとエフェクトが掛かったときの細かなサウンドフォントの扱いなどもまだまだ完全には実装していません。 やることが山ほどあり過ぎるので、ひとまず基本部分が再生できるようになったところでリリースしたという状態です。 完全に動くまで待つとエターなりそうだし。 まずは GM2 をフルサポートし、将来的には GS/XG の一部にも対応しようとは思っています。 実は GM2 をフルサポートしているライブラリってないんじゃないかな。 FluidSynth とかも対応してないです (SoftPedal を実装しているときに気付いた)。 GS/XG に対応し始めると 1流シンセサイザーという感じで、特にエフェクトのチェインが難しいです。 まずはそこを作る前にバグを潰していく必要があります。

GUI

本格的な GUI に関してはこれから作りたい…と言いたいのは山々ですが、現実的にはそうも言ってられません。 なぜかというと WebAudio は現状ブラウザ上でしか確認できないので、テストをするためには GUI が必要だからです。 仕方ないので簡易的な再生用 UI を作ってデバッグすることにしました。 どうせそのうち作ることになる MIDI 再生 GUI を簡単に作れる @marmooo/midi-player を作りました。 @marmooo/midi-player を作る時に基本的な操作で必要な API の確認やテストをしているので、 そんなにバグはないんじゃないかと思います。 やはり GUI 化すると細かな API のズレが出がちですが、自分で作っているからこそバグが起きないようにできたのが大きいです。 基本的な再生部分が完成すれば、もう Promise などでハマる可能性がないので、あとはひたすら実装するだけです。 @marmooo/midi-player はもっと高機能にしていくつもりですが、 あまり高機能にし過ぎるよりはライブラリを分けたほうが良いかも知れません。

Midy をリリースするためは、大量のライブラリに手を入れる必要があって、割と時間が掛かりました。 まだ色々と実装が足りないところがあると思いますが、ボチボチ直していきます。 だいたい動くものとして first commit をするまでが、検証が多くて一番大変。 ここからは平凡に実装とバグ潰しです。 現状はデモで動くものを頑張って探すレベルなので、もっと実装の精度を高めていく必要があります。

2025年1月29日水曜日

JavaScript で使えるタイマーのベンチマークを作った

JavaScript で使えるタイマーのベンチマーク js-timer-benchmark を作りました。 タイマー関数として setTimeout, setInterval がよく知られていて、 画像処理などをやっている人はこれに加えて requestAnimationFrame も知っていることでしょう。 ただ音声処理の実装をしていると、これらではうまくいかないことに気付いたので、 あまり知られていない他のタイマー機能を紹介します。

fontconv

それは AudioBufferSourceNode, OscillatorNode, ConstantSourceNode を使う方法です。 これらは音声データや正弦波、定数値を再生するためのノードですが、 これらには s秒後に再生を開始し、t秒後に再生を止める機能が備わっています。

上記を用いてタイマー機能を作成すると、方法によって様々な精度の違いが生まれます。 精度を検証するためのアプリ js-timer-benchmark はこちら。 Audio 関連のコードは Node/Deno では動かないのでブラウザ上で確認するしかありません。 まずはフォアグラウンドで初回起動時の結果がこちら。

method1sec error2sec error
setTimeout0.9ms0.6ms
setInterval (10)0.8ms0.4ms
setInterval (100)0.6ms0.4ms
AudioBufferSourceNode106.1ms2.2ms
OscillatorNode6.2ms14.3ms
ConstantSourceNode6.6ms20.2ms
requestAnimationFrame9.7ms8.8ms

フォアグラウンドで2回目以降時の結果がこちら。

method1sec error2sec error
setTimeout0.7ms0.4ms
setInterval (10)0.6ms0.4ms
setInterval (100)0.4ms0.5ms
AudioBufferSourceNode8.6ms5.8ms
OscillatorNode9.3ms19.1ms
ConstantSourceNode10.1ms20.4ms
requestAnimationFrame1.0ms0.1ms

バッググラウンドで初回起動時の結果がこちら。

method1sec error2sec error
setTimeout998.9ms900.6ms
setInterval (10)0.5ms999.7ms
setInterval (100)998.8ms999.5ms
AudioBufferSourceNode89.9ms5.8ms
OscillatorNode10.7ms19.9ms
ConstantSourceNode3.9ms20.2ms
requestAnimationFrame34985.8ms30565.4ms

バックグラウンドで2回目以降時の結果がこちら。

method1sec error2sec error
setTimeout998.4ms390.6ms
setInterval (10)1000.2ms0.4ms
setInterval (100)999.5ms999.1ms
AudioBufferSourceNode8.0ms1.3ms
OscillatorNode9.7ms19.6ms
ConstantSourceNode10.1ms19.5ms
requestAnimationFrame14580.2ms2245.5ms

まとめると、requestAnimationFrame はバックグラウンドでは停止しているようなもので使いものになりません。 setTimeout, setInterval はバックグラウンドでは非常に遅くなります。 AudioBufferSourceNode, OscillatorNode, ConstantSourceNode は同じ速度で実行できます。 音楽などはバックグラウンドで再生速度が変わったら困るので、それを考慮しているのだと思います。 AudioBufferSourceNode は初回起動時になぜか遅いですが、それ以降は高速です。 つまり、音声処理のタイマーや、バックグラウンドでミリ秒単位の高精度タイマーが必要なら、 AudioBufferSourceNode を使いましょうという結論になりそうです。 とはいえ setInterval より精度が低いのは意外でした。

ちなみにウィンドウが非アクティブのときにどのように動作するかは、Window: setTimeout() メソッド - Web API | MDN にまとまっています。 setTimeout/setInterval はベンチマークの結果通り、1秒単位で処理するようです。 記述を読む限り AudioContext を使った手法はおそらく処理速度が落ちたりはしないですが、特にそれが定まっている訳でもないみたいです。

2024年10月8日火曜日

様々な言語で作った Wasm をベンチマークした (2)

以前作ったお手軽ベンチマークを高度化してベンチマークの種類を増やしました。 減色処理では色のカウントアップの後に色のリストアップを行うのですが、 そのリストアップ処理までのベンチマークです。 前回 (countColors) は色のカウントアップ、 そのリストアップをして返す getColors、 リストアップした結果は返却せず内部に保持する initColors の 3種類にしました。 getColors は動的配列は使わなくても実装できるのですが、面倒なのでアルゴリズム上の変更はしません。 クラスや構造体や動的配列に対応していない言語、明らかに遅いとわかっている言語は実装しませんでした。 実装は @marmooo/wasm-bench にあります。

fontconv

getColors ベンチマークの追加

getColors ベンチマークでは、すべて JavaScript の性能を下回りました。 結果は以下。
    CPU | Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
Runtime | Deno 1.46.3 (x86_64-unknown-linux-gnu)

benchmark                                   time/iter (avg)        iter/s      (min … max)           p75      p99     p995
------------------------------------------- ----------------------------- --------------------- --------------------------
JavaScript, Deno 1.46.3                            192.0 ms           5.2 (189.7 ms … 201.7 ms) 192.0 ms 201.7 ms 201.7 ms
AssemblyScript 0.27.30 (Number)                    286.1 ms           3.5 (233.6 ms … 322.7 ms) 307.5 ms 322.7 ms 322.7 ms
AssemblyScript 0.27.30 (Class)                     319.3 ms           3.1 (279.6 ms … 370.7 ms) 334.2 ms 370.7 ms 370.7 ms
Rust 1.81.0, wasm-bindgen 0.2.93 (Simple)             2.2 s           0.5 (   2.2 s …    2.2 s)    2.2 s    2.2 s    2.2 s
Rust 1.81.0, wasm-bindgen 0.2.93 (Serde)           337.3 ms           3.0 (316.3 ms … 355.4 ms) 351.8 ms 355.4 ms 355.4 ms
C++, emscripten 3.1.68                             390.2 ms           2.6 (368.9 ms … 401.8 ms) 398.0 ms 401.8 ms 401.8 ms
わかってはいましたが、オブジェクトの転送コストが大きいとものすごく遅くなります。 オブジェクトを転送しなければ早いので、とにかくオブジェクトを転送してはいけないことがわかります。 メモリで転送するのはプリミティブと TypedArray に留めたほうが良さそうです。

C++ は計算後に JavaScript オブジェクトへ変換するのが一番早かったです。 ちまちま JavaScript オブジェクトを触りながら作るとものすごく遅くなります。 この結果を見ると、Wasm では動的配列を JavaScript オブジェクトに変換すること自体が間違いでしょう。 Rust と AssemblyScript はメモリリークで結構ハマりました。 おそらく JavaScript オブジェクトを内部で使うと 解放のタイミングがわからなくなるのでメモリリークします。 AssemblyScript の対策はわかりやすくて、 内部オブジェクトで export しないように気を付ければいいだけです。 Rust もたぶん一緒なのですが、wasm-bindgen が自動変換してくるので見落としがちで、 きちんと手動で JavaScript オブジェクトに変換しないと怖い印象を受けました。

Rust はさらに serializer を使わないと異常に遅くなる問題に直面しました。 Rust はなんでこれが遅いの? が結構ある気がします。 C++ や AssemblyScript は serializer を使うともっと早くなるのかも。 最終的には AssemblyScript が C++ より早くて少し驚きました。 他にも 2^20 くらいのループまでは Wasm のほうが JavaScript より早かったりするのもよくわからないところです。 メモリの再割当てが原因かな。

initColors ベンチマークの追加

getColors のようにオブジェクトの変換コストが大きいと Wasm 化する利点がないので、 変換せず内部に保持するようにした initColors ベンチマークでは、Wasm が圧倒的に高速で、最初の想像通りの結果になりました。 結果は以下。
    CPU | Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
Runtime | Deno 1.46.3 (x86_64-unknown-linux-gnu)

benchmark                          time/iter (avg)        iter/s      (min … max)           p75      p99     p995
---------------------------------- ----------------------------- --------------------- --------------------------
JavaScript, Deno 1.46.3                   188.9 ms           5.3 (173.5 ms … 213.0 ms) 194.7 ms 213.0 ms 213.0 ms
AssemblyScript 0.27.30                    148.8 ms           6.7 (123.7 ms … 187.2 ms) 160.3 ms 187.2 ms 187.2 ms
Rust 1.81.0, wasm-bindgen 0.2.93           70.3 ms          14.2 ( 63.0 ms … 104.4 ms)  74.9 ms 104.4 ms 104.4 ms
C++, emscripten 3.1.68                     43.7 ms          22.9 ( 42.4 ms …  48.8 ms)  44.0 ms  48.8 ms  48.8 ms
AssemblyScript はクラスをグルーコードとして生成できない課題があるので、 constructor と使いたいメソッドだけをクラスの外部に関数として定義するのが良いです。 面倒ではありますが、メモリリークの可能性が減るので悪くはない気がしました。 Rust は記法に慣れていないことによって時間掛かりましたが、そんなに悩みはしなかった気がします。 C++ は Array だと大きなメモリ確保ができなくて落ちることだけハマりました。 この 3つの言語は書くのも楽で、あまり迷うところがないです。 どれも uint8 を使わないほうが型変換が減るぶんだけわずかに早い気もしますが、 省メモリ動作も Wasm の利点なので真面目にしました。

結論としては C++ が鬼のように早いことがわかりました。Rust も結構早い。 opencv.js を見ていると Wasm は JavaScript より 4倍くらい早そうと思っていたのですが、 データ転送もオブジェクト変換もなければ、もっと早いかもなあ。

countColors ベンチマークの改善

前回作ったベンチマークも多言語サポートを進めました。 といっても試してみた結果、まだ実用段階にないことを確認することがほとんどでしたが。

Rust

Rust は速度が気になったので色々な方式で実装してみました。 まずはポインタを返り値として処理するときは C/C++ と同じ速度が出ることがわかりました。 しかし Vec, Box<[u32]> などを返り値として処理すると、 おそらく内部で wasm-bindgen が js_sys を使って自動型変換をしているのですが、遅くなります。 C++ の embind だと自動型変換をしても爆速なので課題を感じます。 他にもJavaScript 側で import の仕方を間違えた場合なども遅くなったりします。 unsafe { Uint32Array::view(&color_count) } の書き方を見つけてからは、これが一番楽そうかなと思っています。

Go

Go はポインタなら C/C++ に近い速度で動作します。 しかしオブジェクトを透過的に扱おうとすると、export ディレクティブが使えず、 JavaScript 化した関数をグローバルにエクスポートする処理を書く必要が出てきます。 サンプルなどを見ると globalThis にエクスポートしているのですが、 これは非常に邪魔なので ESM のように自由な名前でエクスポートしたいところです。 設計面での変更が必要そうです。

またベンチマークを取ると GC がうまく動いていないことがわかります。 Wasm は GC をセルフ実装することになるのでベンチマークを取ってみることは大切そうです。 他にも little endian がデフォルトの JavaScript と、 big endian がデフォルトの Go では整合性を取るのが大変ということがわかりました。 Go に慣れていればこのへん楽かも知れませんが、慣れていないのでね。 さらに syscall/js の Uint32Array から Index(i) して取得したデータをInt() でしか取得できないので、 uint32 で欲しい画像処理の場合、取得した時点で範囲外の数値が壊れます。 byte で取得できないと処理がかなり大変で、関数が不足しているように感じます。 範囲を指定してデータ取得もできないので、色々と限界はあります。 メモリコピーするしか解決策がわかってないのですが、これだとまあ遅くなります。

他にも GC=leaking ならテストが通るのに、その他だとテストが通らないような問題も発生しました。 さらに関数を JavaScript 化すると現状は非常に遅い問題などもあります。 issuses #32591, #46473 にも上がっていましたが、 報告からすでに 5年間も経っているので解決は時間が掛かりそうです。 使えない実装も残してはいるのでこのへんの問題が確認はできるようにしてあります。 実用面はまだまだこれからかなあ。

Zig

Zig は色々試してみましたが、文法から難しくてまだ理解できていないです。 理解の範疇でば Wasm を作りにくい気もしていて、その理由は関数の返り値です。 配列を JavaScript と共有するにはヒープに明示する必要があるようなのですが、 alloc の成否をチェックする必要があって error union ポインタが返り値になります。 しかし C 互換で export する時はこの機能を使えません。といってキャストもよくわからなかったです。 オプショナルポインタで表現するのも駄目なので、どうすれば良いのかわからなかったです。 C/C++ のポインタは実質 NULL を取り得るオプショナルポインタだと思うのですが、わからん。 グルーコードも生成してくれないので実行までのハードルも高いです。 zig-js とかあるし、できないことはないはずなんだけどなあ。

Java

Java はサンプルを見たら、割と簡単に変換できることがわかりました。 やはり設定ファイルがわかりやすいと、初心者にはわかりやすいです。 ただ Java には参照渡しがない問題があるので、どうするのかと思っていました。 案の定、issue #907 を見ていても、 メモリに領域を確保しようとしたときアドレスを渡す手段がない話をしていました。 アドレスを使わないと、バイナリデータの処理は、 値渡しで画像を渡して、処理して、値渡しで返す必要があるので遅いです。 あと uint32 がないので画像はバイト単位でシフト演算するしかないでしょうが、 わずかに遅くなることも予想できます。 つまり countColors と似たなにかは実装できても、同じものは実装できない認識です。 Java で配列などを Wasm を通じて透過的に処理するためには、 事実上変化がなくても変更可能性のある一般的なデータ型の内部位置をコンパイラ側で保証し、 マッピングする必要があるのでしょうかね。 人気言語の中では Wasm 利用に課題が多いかも知れません。

Kotlin

Kotlin は文法が簡単な一方で、設定ファイルが難しいのが欠点です。 サンプルで main 文を動かす方法はわかりましたが、関数のエクスポートがわかりません。 現状は Wasm のドキュメントが皆無なのでカスタマイズができないです。 まだ main 文以外はサポートしていないかな? Java 派生ですが、Java と異なり UintArray があるのでマッピングは楽かも知れません。

Scala

Scala + Scala.js は サンプルプロジェクト を眺めてみたら、 独自の sbt 設定ファイルの内容が難しすぎて、書ける気がしなかったです。 それとも見たサンプルがいけなかったか。Scala も uint32 がない問題があります。

Dart

Dart は文法も簡単だし、簡単に Wasm を生成できるので良い感じです。 ただすぐに使える dart compile wasm コマンドでは main 文の export しか現状できないので、今後に期待です。 dart2wasm という非公開の公式ビルドツールを使うと wasm:export も使えるようなのですが、 ビルドツールを用意するのは大変そうなので諦めました。 wasm:export できるようになれば一気に使いやすくなると思いますし、 公式サポートされたらベンチマークもすぐ追加できそう。 Dart にもポインタがないですが、Uint32List があるのでマッピングは簡単そうです。

MoonBit

MoonBit は簡単なので良い感じですが、グルーコードは生成できなそうに見えます。 クラスや構造体を定義するのはあまりにも面倒なので、進化に期待したいところです。

まとめ

Wasm ビルドのコードをたくさん書いてみた結果、言語自体の話は置いておくとして、 ビルド環境自体の使いやすさは以下で決まるとわかりました。
  1. 簡単にビルドできる (Scala は課題あり)
  2. グルーコードを自動生成できる (MoonBit と Zig は課題あり)
  3. 関数が export できる (Dart と Kotlin は課題あり)
  4. 高速安定動作する (AssemblyScript と Go は課題あり)
  5. JS オブジェクトと自動型変換できる (現状 C++/Rust のみ)
4 までは実利用を考えると必ず欲しい機能です。 まとめると C++/Rust は現状 Wasm を非常に書きやすい言語ということです。 AssemblyScript もかなり良いです。 5 は現状最も実装の進んでいる emscripten や wasm-bindgen を使っても性能は出ないので、 実質的にはちょっとしたクラスや構造体のエクスポートと、 TypedArray をサポートしていることが重要になりそうです。

現状は C++ が一番楽だし高速そうですが、Rust も実用レベルと思います。 今回のコードでは適用できなかったですが、どちらも autovectorizer が組み込まれているので自動 SIMD 化も期待できます。 サクッと Wasm にしたいだけなら AssemblyScript も良い言語だと思います。 他の言語はまだ厳しいかなあ。でもどの言語もまだ Wasm 対応は始まったばかりなので、 今後に期待が良いと思います。次は Kotlin/Dart あたりが面白そうかな?

2024年9月22日日曜日

様々な言語で作った Wasm をベンチマークした (1)

Wasm は様々な言語から作ることができますが、その実行速度が気になったので調査しました。 計測に使ったのは減色処理をする時に必要な、画像内にある色のカウントアップ処理です。実用的です。 WasmGC を使うとまた特性が変わるような気もするのですが、ひとまず現状チェックです。 実装は @marmooo/wasm-bench にあります。

fontconv

独断と偏見により AssemblyScript, C/C++, Rust を調査しました。 本当は Go も確認はしていて、int 処理くらいならできたのですが、 Uint8Array を引数として渡すことができなくて諦めました。 最近は TinyGo がかなり小さな Wasm を生成できるようになっていて (70KB〜)、速度が出るなら十分候補になるような気がします。 しかし Uint8Array などの型変換がまったくわからない…。

Zig も試してはみましたが、文法がわかってないのでうまくいきませんでした。 Zig のほうが文法はシンプルなので粘ればできたかもですが、ChatGPT さんも手助けにならないので粘っていません。 他に有名なのは Grain, MoonBit あたりでしょうか。 ざっとドキュメントを読んだ感じだと MoonBit は Uint8 がないのでまだ早い。 Grain はアリかも知れない。 ChatGPT さんの手助けが期待できるくらいになったら再チャレンジしてみるかも。

ベンチマーク結果

結論から言えば、C/C++ が最速で、その他の Wasm は JavaScript よりちょっと早いでした。 また C/C++ 以外の Wasm にほとんど差がないのがポイントです。
    CPU | Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
Runtime | Deno 1.46.3 (x86_64-unknown-linux-gnu)

benchmark                           time/iter (avg)        iter/s      (min … max)           p75      p99     p995
----------------------------------- ----------------------------- --------------------- --------------------------
JavaScript, Deno 1.46.3                    166.8 ms           6.0 (164.1 ms … 171.8 ms) 169.6 ms 171.8 ms 171.8 ms
AssemblyScript 0.27.29 (Wrap)              153.8 ms           6.5 (150.1 ms … 155.0 ms) 154.4 ms 155.0 ms 155.0 ms
AssemblyScript 0.27.29 (Shift)             175.2 ms           5.7 (174.1 ms … 175.8 ms) 175.4 ms 175.8 ms 175.8 ms
AssemblyScript 0.27.29 (DataView)          156.5 ms           6.4 (156.0 ms … 157.8 ms) 156.5 ms 157.8 ms 157.8 ms
Rust 1.81.0, wasm-bindgen 0.2.93           147.4 ms           6.8 (142.9 ms … 154.1 ms) 147.4 ms 154.1 ms 154.1 ms
C, emscripten 3.1.67 (Simple)              100.7 ms           9.9 ( 99.7 ms … 101.5 ms) 100.9 ms 101.5 ms 101.5 ms
C, emscripten 3.1.67 (Struct)              100.0 ms          10.0 ( 93.0 ms … 101.5 ms) 100.9 ms 101.5 ms 101.5 ms
C++, emscripten 3.1.67 (Simple)            102.6 ms           9.7 (100.5 ms … 103.3 ms) 103.0 ms 103.3 ms 103.3 ms
C++, emscripten 3.1.67 (Class)             102.1 ms           9.8 (101.6 ms … 103.4 ms) 102.2 ms 103.4 ms 103.4 ms

AssemblyScript

AssemblyScript は、ほぼ TypeScript で記載できる Wasm 生成用言語です。 Union と型エイリアスと destructures を使えないのがつらいけど、まあなんとかなります。

コンパイルオプションが非常に多いです。 色々と最適化しながら計測してわかったことは、Wasm の実行速度の大半は GC に依存していることです。 GC を切ると 2倍以上速度が上がります。 デフォルトの Incremental GC では JavaScript より遅いですが、 --runtime minimal --exportRuntime オプションを付けて GC を手動にすると、 Rust で作った Wasm と同じ速度になり、安定します。

デフォルトの Incremental GC はかなり実行速度にブレがあって、 少し書き方を変えるだけでとんでもなく速度に差が出ます。 先ほど 2倍以上と書きましたが、書き方によっては 4倍くらい遅くなります。 これだと検証が大変になってあまりよろしくないですが、 手動の GC にするだけで非常に安定した性能になることから、 性能低下の大半が GC によるものだとわかります。 GC が性能の大半を占める特徴は、他の言語で作った Wasm にも言えることだろうと思います。

現時点では手動 GC でないと厳しい印象ですが、 そこさえ許容すれば実用に耐える状態に仕上がっている印象です。 ベンチマークにも AssemblyScript を使った様々な実装を残してあるので、 オプションを変えて実行すれば Incremental GC の挙動は確認できます。

AssemblyScript は GC 以外にも様々なオプションがありますが、 GitHub の issues などを眺めていると、 ファイルサイズは最適化しないほうが速度面で良いようです。 まあインライン化などの最適化をした時にサイズが増えることから、想像は付きますね。 現在利用している実行オプションは以下のようになっていますが、一番よく効くのは先に書いた --runtime minimal --exportRuntime で、 残りのオプションはちょっとずつ効果があるという感じです。
asc countup.ts -o countup.wasm --bindings esm \
  --runtime minimal --exportRuntime \
  -O3 --converge --noAssert --uncheckedBehavior always

C

C はポインタ以外は JavaScript と書き味が一緒なので、割と気軽に書けます。 面倒なのはたいていドキュメント不足の環境整備ですが、Wasm を作るだけなら emscripten をインストールするだけなので迷うことはないです。 C/C++ で作った Wasm はメモリリークの危険性がある手動管理です。 GC でマークを付けないことが大きそうで、Rust / AssemblyScript より明確に早いです。 そんな訳で Wasm で速度を出したいときには C/C++ が最強のような気はします。

ただ Rust / AssemblyScript で作った Wasm と異なり、JavaScript からの呼び出しが面倒です。 malloc してポインタとサイズを渡して使い終わったら free が必要です。 データのサイズを渡さないといけないので実装が複雑になります。 他の言語に慣れているとかなり面倒で、早いとわかっていてもあまり使いたくないです。 AssemblyScript で同様のメモリ管理機能ができたら、絶対そっちを使うと思う。 薄いラッパーがあれば欲しいです。ラッパーがあればかなり使いやすくなる気はします。 というわけで JavaScript でお手軽な Struct <-> TypedArray ラッパーを書いて、簡単に使えるようにして (?) 比較してみましたが、速度低下はありませんでした。

実行オプションはこんな感じ。実質 -O3 だけで、他は利用しやすくするための設定です。 今回書いた程度のベンチマークでは大差ないようですが、 -flto --closure 1 も入れておくと良い みたいです。 -s EXPORTED_FUNCTIONS=_malloc,_free は入れておかないとメモリ管理ができないので、実質的に必須です。
emcc countup.c -o countup.js -O3 -flto --closure 1 \
  -s MODULARIZE \
  -s EXPORT_ES6=1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s EXPORTED_FUNCTIONS=_malloc,_free

C++

C++ もポインタ以外は JavaScript と書き味が近いので、まあなんとかなります。 C++ で作った Wasm は Embind がクラスと関数の定義を渡せば自動でバインディングしてくれます。 バインディングの方法は理解するのに非常に時間が掛かりましたが、わかれば非常に使いやすいです。 コンパイルするときはクラスをバインディングするために --bind を付けるだけです。 固定配列ではなく std::vector にバインディングされるので、将来的に TypedArray が可変的になった時にも対応しやすいです。 TypedArray は今は固定長だけど、ES2024 Resizable ArrayBuffers には注意が必要なのかも知れません。 でも C++ なら心配なさそう。 欠点は Wasm のサイズが少し大きくなることですが、許容範囲です。 Wasm を作るのにはかなり良い言語だなと思いました。
emcc countup.c -o countup.js -O3 -flto --closure 1 \
  --bind \
  -s MODULARIZE \
  -s EXPORT_ES6=1 \
  -s ALLOW_MEMORY_GROWTH=1

Rust

最近の高速化でよく使われる癖つよ言語ですが、Wasm は割と簡単に作れます。 wasm-pack をインストールして、Cargo.toml に設定するだけで作れるので、言語仕様の理解だけが問題です。 正確な速度差はさらに調査しないとわかりませんが、上記の結果では Rust で作っても AssemblyScript で作っても速度差はそれほどなさそうと思いました。 癖つよの言語仕様を覚えるよりかは、AssemblyScript でも良いかなと思ったりもしました。

ただ、特別なメモリ管理をしないでも速度が出る利点を考えると、やはりライブラリには使いやすいのかな。 今回調査した以外の言語で Wasm を作る場合も、特別なメモリ管理をしないでも速度が出るかどうかは、それなりに重要な差になりそうです。 Cargo.toml は以下の最適化を入れています。
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"
あとは wasm-pack で release モードの Wasm で出力するだけです。
wasm-pack build --target web --release


ネットでは AssemblyScript が遅い!の記事が多かったのですが、最適化すると割と戦えるっぽいです。 もうしばらく AssemblyScript で遊んでみようと思います。 --runtime stub にしたとき C/C++ と同じようなメモリ管理ができると良いのですが、できないっぽいかな? いまいちよくわからないです。 ベンチマークの種類も増やすかも。

2024年6月9日日曜日

Photo Scanner を大幅に改善した

スマホをスキャナーにできるアプリ Photo Scanner を大幅に改善しました。 気付けばスキャンアプリも色々できていたので、改めて色々考え直してすべて再実装しました。 もはやコンセプト以外は原形を留めてませんが、まあ仕方ない。



1. 自動矩形探索の廃止→手動設定の追加

リアルタイムに画像からプリントを探索する機能、 具体的には cv.findContours() → 最大領域を四角形として自動判定する機能は、 精度が残念なのでやめました。 脳死して cv.findContours() だけだと、ちょっとの歪みや折れがあると安定した矩形探索はできません。 自動認識ではどうやっても意図しない動作が生まれます。 実装を頑張ったとしても、白黒領域が混在したプリントや、 カラフルなプリントに対応するのは、かなり難しいと言えます。 ちなみに Object Detection はモデルがデカすぎて使いにくいです。 そこで自動認識にすべてを委ねるのはやめて、手動で矩形を指定してもらい射影変換することにしました。

2. 矩形予測機能を追加

手動で矩形を指定してもらうのは手間なので、撮影後にプリントの予測点を提示することはやっています。 矩形点の予測は、Canny Edge Detection を使う方法などもありますが、単純な方法ではやはりうまくいきません。 ただ色々やっているうちに、cv.findContours() を使う場合、cv.medianBlur() などのノイズ処理が大きく精度に影響するとわかりました。 また普通に考えると、プリントをスキャンしたい場合は画質の問題から一つずつ撮影します。 そのため画像内には 1枚のプリントしかない仮定を置いてもまず大丈夫のはずです。 その場合 cv.findContours() →最大領域を cv.minAreaRect() が安定しそうなので実装してみました。 実際に動作は安定しており精度も良いので、割と満足です。ただ完璧とは思わないので前述の通り、手動設定できるようにしました。 他の方法では Canny Edge Detecttion と HoughLines を組み合わせる方法を見つけました。 未確認ですが複数のプリントを読み込む場合には良いかも知れません。

ところで OpenCV 4.10.0 から findContours() の処理結果が以前とは変わり、 cv.RETR_EXTERNAL の領域最大サイズが画面全体を示すようになりました。 画面内で1番大きな領域を取得できなくなったので、 cv.RETR_LIST などですべての輪郭を取得し、2番目に大きな領域を取得するのが良さそうでした。 このへんの動作も以前とは異なるはずです。何もしないのに壊れて割とハマりました。

3. Wasm SIMD/Threads 対応

OpenCV 4.10.0 から SIMD/Threads のビルドが簡単になったので、 SIMD/Threads に対応しています。速度面ではもうネイティブアプリと大差ないはずです。 GitHub Pages だとデフォルトでは動作しないので、cross-origin isolation で動くようにしています。 Wasm の様々なビルドオプションに対応するのが手間で、デバッグと Service Worker の設定が面倒なので、もう少し簡単に動いて欲しい。

速度面ではまったく不満がなくなりましたが、現状ではファイルサイズの課題があります。 おそらくその原因は一つの関数に大量のオプション実装が含まれているからです。 cv.findContours() → 最大領域を cv.minAreaRect() するだけでも 1.4MB 必要です。 OpenCV.js を使うのが癪で JavaScript での実現も一瞬だけ考えましたが、 画像処理は Wasm のほうが明らかに高速なのでつらい。 今回は諦めポイントの 1つですが、この記事を参考にして C/C++ でコードを書いて Wasm 化すると、 コンパイラが非常に強力なこともあって、インライン展開による最適化が期待できそうです。

4. Deep Denoise の廃止→様々な手動加工に対応

撮影後に自動的に深層学習を使った簡易的な影の除去処理を加えていましたが、 遅いので撮影後に編集モードで手動処理してもらうことにしました。 深層学習ではないアルゴリズム的なノイズ除去やフィルター処理もたくさん付けて、 高機能な加工ができるアプリになりました。2値化やシャープネスは割と使えると思います。 2値化は矩形領域の探索にも使っている処理の応用ですが、 いざブラウザ上でリアルタイムに実行できるように実装すると、 どのパラメータが重要か一目でわかって非常に良かったです。

たとえば cv.adaptiveThreshold() は C が超重要なことも今さら知りました。 その結果を活かして矩形探索の処理も安定化させたので、何事も作って見るものです。 それ以外のフィルター処理は glfx.js を利用しているだけなので、実のところ実装は簡単でした。 glfx.js は前から知ってはいたのですが、こんなに便利だとは気付いていなかった。 深層学習による影の除去はまた取り組んでみたい気持ちはありますが、 みんな脳死で深層学習なので、最近は深層学習しないほうが面白そうと思ってます。

5. Modal 廃止→画像編集ソフトっぽく

撮影後のプレビューは Modal だとわかりにくかったので、 カメラ表示していた部分に画像と操作ボタンを表示するようにしました。 実利用ではタブレットよりスマホのほうがよく使うと考えられますが、 スマホで Modal はもはや画面いっぱいに最大表示をしているのと一緒なので、 Modal は使い勝手が良くないです。 この変更によって画像編集エディタとしても割と使いやすくなった気がします。

他の細かな改善点を上げるとキリがないですが、 実装をコンポーネント化したり、高速化したり、細かなバグフィックスをしたり、 設定項目を削減して代わりに手動設定できるようにしたり、 かなり色々やりました。実装もほとんどやり直して、わかりやすいものにしました。 今回は Web Components はあまり使ってないですが、 すぐに切り替えられるような Class として実装しています。 これくらい処理が複雑なアプリになってくると、 Class 化によるコンポーネント化は行ったほうが明らかに楽ですね。 はるか昔に作ったアプリだったので色々残念なところがありましたが、 だいぶ満足のいく実装になりました。

UI もスマホのカメラアプリに似せて使いやすくしました。 スマホで Canvas を使ったらだいたい position:absoulte; などでコンポーネントを被せていく感じになり、割と難しいです。 スマホに寄せて実装すると PC の UI がシンプルすぎて使いにくい気もしたので、ちまちま修正していくつもりです。 PC だとやっぱり画面のシンプルさより、編集ツールとしての使いやすさのほうが欲しいです。 まだ実装したいことは色々ありますが、アプリとして利用できるレベルに到達したのでひとまず公開です。

2024年5月6日月曜日

Ubuntu デスクトップ比較: 24.04 は波乱の予感

Ubuntu 24.04 がリリースされたので、いつも通り Ubuntuデスクトップ比較をしてみました。 比較条件は 22.04 のときと一緒で、以下。 22.04 の時と条件は一緒ですが、言語は明示的に英語で固定しています。 これはインストーラで日本語を選べるものと、選べないものがあるからです。 普通は日本語の環境を比較したい人のほうが多いと思いますが、英語表示のほうがメモリ使用量には優しいため、一部のフレーバーが有利になってしまいます。 たとえば Lubuntu は日本語表示にすると 100MB くらいメモリ使用量が増えます。
  • VirtualBox でクリーン環境を比較
  • 検証時の最大メモリサイズは 2GB
  • 起動→安定後のメモリ使用量を free --mega で比較
  • 言語は英語

結果はこちら。 検証では、今まで忘れていた Cinnamon を追加し、新たに Unity を追加しました。 新しいフレーバーの Unity は Ubuntu GNOME が Ubuntu になった後に、 元の Ubuntu を Ubuntu Unity としてフレーバー化しただけです。

悲しいお知らせとしてどのフレーバーもメモリ使用量がかなり増えました。 どのフレーバーも 300MB ほど確実に増加し、MATE, Kubuntu の増加量はさらに大きいです。 MATE くらいのメモリ使用量だと、最適化した Windows 11 と大差ない気がします。 メモリ 4GB の PC だとそろそろ辛そうなので、これからは 8GB のほうが良いかも知れません。 今のところ Lubuntu, Xubuntu が鉄板であることは変わらないですが、 新しく登場した Unity が Xubuntu 並で健闘しています。
  1. Lubuntu: 644MB〜650MB (最軽量)
  2. Xubuntu: 842MB〜857MB
  3. Ubuntu Unity: 890MB〜911MB
  4. Kubuntu: 980MB〜1021MB
  5. Ubuntu Budgie: 1043MB〜1059MB
  6. Ubuntu MATE: 1115MB〜1197MB
  7. Ubuntu: 1135MB〜1262MB
  8. Ubuntu Cinnamon: 1221MB〜1228MB (最重量)

メニューを表示したときのスクリーンショットをまとめると、こんな感じ。 メニューが下ではなく上に行ったものが多い印象です。 どれも洗練されていて GUI に不満はまったく感じません。 慣れているせいもあるかも知れませんが、Lubuntu はやはりかっこいい。

Ubuntu 24.04 で一番の更新点は、おそらくインストーラの更新です。 最初はなんか意味あるのか? と思いましたが、 Lubuntu などを日本語でインストールできるのが大きいかも。 まだ初期画面が英語のほうが多いですけど、シェア獲得のために本気を出し始めたのかも知れません。 GUI に不満が見つからないのも、そういった理由から細かな改善を行ったのかも知れません。

Lubuntu
Xubuntu
Ubuntu Unity
Kubuntu
Ubuntu Budgie
Ubuntu MATE
Ubuntu
Ubuntu Cinnamon
Ubuntu フレーバーもだいぶメモリ使用量が増えてきたので、 その他の超軽量ディストリビューションもじょじょに気になってきています。 Ubuntu フレーバーが重いとなると個人的には Debian 系が有力候補となります。 メモリ 4GB のマシンでは、このへん を参考にして antiX や SparkyLinux、Debian あたりも検討してみると良いかも知れません。Puppy Linux も良さそうです。

ただ軽く Debian LXDE 12.5.0 を触ってみたところ、メモリ使用量は 630MB でした。 Debian もだいぶメモリ使用量が増えています。 全体的にメモリ使用量が増えてしまっていることは仕方ないのかなあ。 antiX は種類が多いので一言では片付けられませんが、300MB 以下で動くものもありました。 どこで差が付いているのでしょう。 LxQt などのモダンな Window Manager に頼ると、そろそろ厳しいのかな。

追記: Debian 11→12、Ubuntu 22.04→24.04 でメモリ使用量が大幅に増えているのは、Linux カーネルが理由のようです。 大幅に増えているように見える理由は、これまで free コマンドで共有メモリ部分を計測していなかったのを計測するようにしたらしいからです。 今後は cat /proc/meminfo で計測したほうが良さそうです、

2024年3月30日土曜日

drop-inline-css でインライン化レベルを最適化する

CSS 最適化ツール drop-inline-css を更新し、drop-inline-css でインライン化レベルを最適化できるようにしました。 一部分だけ不要 CSS をチェックしたり、一部分だけはチェックをせずそのまま inline 化できたりします。 めちゃくちゃ便利な気がしますが、まあ私だけかな…。

drop-inline-css

どんなことができるなったかというと、こんな感じの HTML を、
<html>
  <head>
    <link class="drop-inline-css" rel="stylesheet" href="inefficient.css"></link>
    <link class="inline-css" rel="stylesheet" href="efficient.css"></link>
    <link rel="stylesheet" href="keep.css"></link>
  </head>
  <body>
    <p>styled</p>
  </body>
</html>
こんな感じに inline 化します。drop-inline-css クラスは HTML 構造を見ながら不要な CSS を drop するように最適化し、inline-css クラスはそのままインライン化し、keep.css には適用しないようにしました。
<html>
  <head>
    <style>p { text-decoration: underline; }</style>
    <style>pre { color: red; }</style>
    <link rel="stylesheet" href="keep.css"></link>
  </head>
  <body>
    <p>styled</p>
  </body>
</html>
なぜこのような実装を入れたか。 CSS は最適化を考えた時、最適化の可能性は大まかに 3種類に分けられます。
  1. 静的な HTML から要不要が判断できる CSS
  2. 動的な HTML ではあるものの事前記述で要不要が判断できる CSS
  3. JavaScript をよく見ないと要不要が判断しにくかったり設定が面倒な CSS
私が作っているアプリの例を上げると、以下が例として当てはまります。
  1. Bootstarp の CSS だけで動く大部分のコード
  2. Bootstrap の JavaScript が必要なコンポーネント
  3. autocompletr や simple-keyboard などのライブラリ
1-2 は drop-inline-css を適用し、3 は平凡に inline 化するのが良いと思われます。 うまく最適化すれば Bootstrap などは 1/10 以下のサイズで利用できます。

また上記 2-3 の CSS は遅延ロードが可能で、その最適化は 3種類に分けられます。
  1. <link rel="stylesheet" href="base.css" media="print" onload="this.media='all';this.onload=null;">
  2. template タグ内で style を load
  3. template タグ内で link タグを load
1 は昔なら有名なテクニックでしたが外部ファイルが必要で小さな CSS に適用しにくいです。 今は Shadow DOM がサポートされたので、 2 が処理を後回しにしながら inline 化でき小さな CSS に適用しやすいです。 3 なら通信も後回しにできるので大きな CSS にも適用できる利点があります。

こうして考えてみると、CSS の最適化には (1) drop-inline-css する、(2) drop-inline-css した上で遅延ロード1する、 (3) inline 化する、(4) 何もしないの 3つが考えられ、それらを選択できる必要があります。 これまで drop-inline-css では 1,2 ばかり考えていたのですが、 2 は実装がやや面倒なので deprecated でも良さそうで、 代わりに 3,4 ができると嬉しいなと思って実装してみました。

具体的には link タグに drop-inline-css クラスがある時には drop-inline-css し、 inline-css クラスがある時には平凡にインライン化し、何もないときは何もしないようにしました。 この改良によっていくつかのアプリの実装がかなりシンプルになるとわかりました。 破壊的変更で更新が大変でしたが…。

2024年1月2日火曜日

Unicode の部首を一意にする

@marmooo/kanji の部首データを Unicode 全漢字に対応させました。 @marmooo/kanji では画数データは一意になるように設計しているのですが、 Unihan Database の部首は Unicode 15.1 では 183 件の重複登録があり、この解決に苦労が伴ったのでメモを残しておきます。 ちなみに Unihan Database では画数の管理で int を諦めて string を使っているのですが、 情報が不確かな漢字のために int を放棄するのは嫌なので int にしています。 具体的なコードはこちらを参照してください

ちなみに画数も重複データがあるのですが、3件だけなので画数の対処は簡単です。 より正確に言うと kRSUnicode には大量の重複データがあるはずなのですが、kTotalStrokes には重複が 3件だけです。 ただこれも問題のある話で、たとえば「滋」を kRSUnicode で見ると部首以外の画数が 9画と 10画の両方があると登録されていますが、 それなら kTotalStrokes もデータが 2つないとおかしいんですよねえ。 同じような問題は草冠でたくさん見つかるので、真面目にやるなら 漢字データベースプロジェクトのようなチェック が必要になるのでしょうね。 将来的にはやってみるかも知れませんが、IDS も更新しないといけないので時間が掛かります。

部首データの 183件の重複のうち、大半はなぜ複数あるのかよくわからないものが多いです。 康煕字典に記載があるものはなるべくそちらに沿った部首を利用するのが筋だと思います。 康煕字典に記載がないものが問題で、例えば「习」の解決には苦労しました。 「习」は Unihan Database では「冫部」または「乙部」と登録されています。 しかし「习」は 1956年の漢字簡化方案で「習」を簡略化して作った漢字なので、「冫部」または「乙部」の字義は存在せず、その登録でいいの?となります。 ただ漢字簡化方案の元情報が見つからないので、どうしようもない。 本当は部首情報があったのかも知れませんし、元々なくて字体から推測しただけかも知れません。

同じように中国では簡体字を作ったときのことをよく考えないといけない漢字が多々あります。 たとえば日本だと「卧」はそのままの字形で理解されていて Unihan Database では「卜部」です。 ただ中国だと「臥」が元字で、簡化によって「卧」になったので、康煕字典に合わせた部首は「臣部」なんですよね。 康煕字典に記載があるからといって、合わせすぎるのも難しそうです。新しい漢字と古い漢字は分けて考えないといけません。

他には「凬」も難しいですね。字義としては康熙字典の「風部」ですが、Unihan Database だと「⼏部」、文字情報基盤は両方登録されています。 日本だと風が由来でも「⼏部」になることが多いようなので、まあ「⼏部」でいいのかな。 でも中国や台湾でどうかと聞かれると、なかなか難しいところですね。

「厼」のように康熙字典網にも文字情報基盤にも登録されていない漢字も、データがどこから来たのかよくわからないで困ります。 ネットで調べてもほぼ情報がなく、なぜか一番詳しいのが 日本語の Wiktionary みたいなケースがチラホラあります。 当然ながら部首がわかるはずもないので、Unicode に従うしか方法はないでしょう。 ちなみに「厼」の本字は「彌」のようで、そこから様々な異体字と新字体が生まれているようです。 異体字はどれも部首がバラバラでちょっと笑えます。文字を簡単にしようとすると部首が崩壊するんだなあ。 つまり本字以外の部首は深く考えたら負けで、特徴的なパーツを選び、Unicode 配列に従っておけば良さそうです。 また本字以外は部首より、異体字をたどったり、IDS で検索するほうが良さそう。

分類の難しい漢字は、可能な限り理由付けやチェックをしていますが、基本的には使いやすさを考えて Unicode 配列に合わせていますし、それで特に問題ないと思っています。 今のところ Unicode 配列から明確にずれた状態で登録しているのは BMP なら「丬」の「⽙部」だけですね。 SIP では U+20064 の「⼞部」、U+2B820 の「⼷部」、U+2B8D9 の「⽊部」、U+2B8DA の「⽊部」です。 BMP はともかく SIP は部首をどれにするか難しいものがいくつかありますねえ。 日本なら上記以外のものは Uicode 配列に従って良いと思います。 中国だと先に述べたような漢字がちょっとわかりにくい気がしますね。 台湾でも同じような問題があるかも知れません。 ただ Unicode 配列だからこれでいいのだと言い張れば、まあいいかという気持ちになれるのは大きいかな。

追記: Unicode® Standard Annex #38 の画数の説明 を読んでいると、一意にしようとはしているみたいですね。 あまり手を出さないようにしつつなるべく一意にする今の実装方針で良いんじゃないかなという気がします。

2024年1月1日月曜日

SVG の描画要素を path に変換する shape2path を作った

SVG の描画要素 (rectangle, circle, ellipse, line, polyline, polygon) を path に変換する shape2path を作りました。

shape2path

以下の類似ライブラリが存在することはわかっているのですが、Web 上で動かなかったり、実装不足があったり、XML パーサが巨大すぎるので、依存なしでシンプルに動くものを作りました。 依存がキツかったり fs module 依存が邪魔なときに毎回ゼロから作り直しているのですが、どうするのが良いのかなあ。 たぶんどうしようもないので、Node.js / Deno / Bun に依存するコードはなるべく使わないのが鉄則でしょうね。

ブラウザとオフラインの実装を分けるのが難しい

作っているときにも同じような問題にぶつかって、HTMLElement や HTMLDocument の実装が Deno / Node にはないのでどうするか、となりました。 特に HTMLDocument クラスはどんなライブラリでもブラウザで動かす時とオフラインで動かす時で動作が異なる問題が発生します。 document がある時 (Web) はバンドルサイズを小さくするために import なしでも動くようにしたいものです。 しかし import 処理を分けようとすると、動的 import を使うしかなく、よくわからないことになります。 こういう時に ESM って不思議な力で動いてるんだなと感じたりします。 たぶんブラウザの時だけ import しないようなことって今のところできないよね?

すべてを諦めて DOM を再現するライブラリを使うのが楽ではあるのですが、バンドルサイズは大きくなります。 この路線でいくなら linkeDOM は互換性が高く、速度もまあまあなので良さそうです。 とはいえ 300KB くらい増えてしまうので依存性をゼロにしたいなと思い、HTMLDocument は無理やり使わないようにして、関連するオブジェクトとメソッドを引数で渡す実装にしました。 これなら依存性が引数として与えられる外部の HTMLElement だけになり、かなり動かしやすくなります。 ただこの路線での実装は、ブラウザと完璧な互換性を持っているライブラリがほとんどない問題があります。 たとえば私がよく使っている node-html-parser は HTMLElement.cloneNode() をサポートしていないので、多少ながら違和感が残る実装になりました。 ただこういう違和感はブラウザに慣れ過ぎているからで、ESM 的な観点から考えると普通なのかも知れません。 DOM の API を使ったライブラリは作り方が難しいですね。

円/楕円の変換方式

上述のライブラリでは、どれも円/楕円の変換方式を一つしか用意していません。 しかしそれだと改良が難しいので、困るケースがままあります。 そこで SVG の <circle> を <path> で描く を利用しています。 描画パターンを選択できると嬉しいと思ったので、すべてサポートしています。 私のお気に入りは「8本の2次ベジェ曲線で描画」です。 描画精度は低い反面、応用性が高いです。

2023年12月1日金曜日

漢字の情報取得ライブラリ kanji を作った

今さらですが、欲しくなったので漢字の情報取得ライブラリ @marmooo/kanji を作りました。 漢字の情報と言っても色々ある訳ですが、レベルごとの漢字のリストを取得し、文章のレベルを取得するライブラリです。 特に教育指導要領をチェックするコードは何十回も書いているので、そろそろライブラリが欲しいと思って自作しました。 ついでにかなり色々な機能を付けたので、つよつよです。 漢字の処理は非常に難しいので、このような誰も作らない基盤ライブラリの整備が必要です。

こんな感じで使えます。
import { Kanji, JKAT } from "@marmooo/kanji";

const kanji = new Kanji(list);
const jkat = new Kanji(JKAT); // 教育指導要領 / 日本漢字能力検定
jkat.getGrade("学校");   // --> 0
jkat.getGrade("漢");     // --> 2
jkat.getGrade("うどん"); // --> -1
Kanji クラスの初期化で漢字のリストを読み込むので、カスタム化もできます。 今のところ以下の 9つをサポートしています。 今後も Unicode3Radical, Unicode3RadicalStrokes など追加したい機能はあります。
  • JISCode: JIS 漢字コード (第 1 水準〜第 4 水準)
  • Jinmei: 人名用漢字 (常用漢字, 常用漢字の異体字, 人名用漢字)
  • JKAT: 教育指導要領 / 日本漢字能力検定 (10級〜1級)
  • Unicode1Radical: Unicode 1.1 の康熙字典 214 部首コード
  • Unicode1RadicalStrokes: Unicode 1.1 の康熙字典 214 部首の画数データ
  • JoyoRadical: 常用漢字の康熙字典 214 部首コード
  • JoyoRadicalStrokes: 常用漢字の康熙字典 214 部首の画数データ
  • JoyoStrokes: 常用漢字の画数データ
  • JIS4UnihanStrokes: JIS 第 4 水準の Unihan_IRGSources.txt に基づく画数データ

開発に当たっては色々なことを調査・検証したので、その内容についても記事としてまとめました。

ライブラリ化するときにはとにかく扱いの難しい漢字として「𠮟」があります。 「𠮟」は新しい常用漢字ですが、[音訓の小・中・高等学校段階別割り振り表(平成29年3月)] を見ると、「𠮟」じゃなくて「叱」で書かれているんですよねえ。 漢字検定の漢字一覧も表記は「𠮟」なのですがコピペすると「叱」になったりして困ります。 他にも間違いがいくつかあって紛らわしい。 そういったものは実用上どっちでも良いとされているのがほとんどと思いますが、それならなぜ「𠮟」を常用漢字にしたのだ?という感じはあります。 IME とかでも「しかる しっする」では変換できず、「しつ しった」くらいでしか変換できないので、面倒臭い漢字です。 デザイン差でしかないなら、もう「叱」で良いんじゃない? という気もしますが、ライブラリでは両方考慮することで統一しました。

JLPT も対応しようと思ったのですが、漢検のように情報公開されてないので、 きちんとしたものは作れないと思って無視しました。 リストがないなら漢検の順序で学べば良いはずです。

漢字の画数を調査した

こども漢字辞書 を改善するために、漢字の画数を調査しました。

漢字の画数を取得できるリソースは様々ありますが、厳密なチェックをしたことはありませんでした。 使えるデータの中でどれが一番信用できるか常用漢字のリストをチェックしたところ、以下になりました。 特に酷いのが UnihanXML で、2136 文字中、2052 文字ずれています。もはや合っているほうが少ない。これはひどい。
$ deno run -A check-stroks.js
check 2136 kanjis:
diff animCJK: 1
diff joyoKanji: 5
diff mojikiban: 5
diff cjkvi: 145
diff unihanIRGSources: 177
diff unihanXML: 2052
やはり画数を真面目に扱っている KanjiVGanimCJK が一番正確です。 joyoKanji は古い常用漢字しか対応していないため 4つ間違いが増えていますが、データとしては完璧です。 MJ 文字 も良い精度ですが、フォーマットが地獄という問題があります。 それ以外の画数データは使えません。 ちなみに KanjiVG, animCJK, joyoKanji で画数が異なる文字は一つだけしかありません。 それが「衷」で、9画と 10画のどちらが正しいのかという問題があります。 結論から言えば どちらでもいい ようですが、 学校を基準にするなら 9画が正しいようです。

常用漢字までなら Public Domain のjoyoKanji に修正を加える形で再利用ができるとわかったので、 @marmooo/kanji にも常用漢字の画数データを追加しました。 以下のように画数のレベル情報を取得できます。
import { Kanji, JoyoStrokes } from "@marmooo/kanji";

const joyoStrokes = new Kanji(JoyoStrokes);
joyoStrokes.getGrade("学"); // --> 8
joyoStrokes.getGrade("校"); // --> 10
joyoStrokes.getGrade("学校"); // --> 10

常用外漢字

常用漢字以上については、もう画数とかあんまり関係ないんじゃ…とは思うものの、 サポートするなら KanjiVG、ついで mojikiban の信頼性が高いように思います。animCJK は精度が下がるようです。 ただ上記はどれも人名用漢字をサポートしておらず、クロスチェックがしにくいため、常用漢字にとどめておくのが現状では無難そうです。 人名用漢字はそれなりに使われるものの割に、使えるデータは少ない印象です。 ちなみにライセンス不明なので現時点では使えないですが、kanji_kakusuu は人名用漢字もサポートしているみたいです。 人名用漢字以上を無理やりサポートするなら unihan_IRGSources.txt を改良した cjkvi が良さそうですが、古いですしミスは必ずあります。

unihan_IRGSources.txt は日々改善されていて、ver.2023-07-15 を使うと、JIS 第 4 基準でも cjkvi と精度的に大差なく、479 個のズレしかありませんでした。 unihan_IRGSources.txt が正しい保証はありませんが、使えると便利とは思ったのでこれも @marmooo/kanji でサポートしています。 unihan_IRGSources.txt を使えば JIS 第 4 基準以上のサポートもできるのですが、そもそもたいていのフォントで漢字を表示できないので、表示限界の JIS 第 4 基準 を採用しました。


漢字の部首を調査した

こども漢字辞書 を改善するために、漢字の部首を調査しました。

漢字の部首を取得できるリソースは MJ 文字KanjiVGJoyoKanji の 3つがありますが、厳密なチェックをしたことはありませんでした。 部首は表記の仕方がバラバラなのでチェックをするのが思ったより大変でした。

部首の表記ゆれ

たとえば部首の「食部(しょくぶ)」は 「食 飠」の2 表記があります。 このとき「食→ショク」、「飢→しょくへん」と分かれるはずだと考えるのが KanjiVG です。 他のリストは康熙字典 214 部首を表示しているだけで、どちらが正確かと言えば、これは間違いなく KanjiVG です。 なぜなら康熙字典 214 部首だけだと、食「しょく」なのか飠「しょくへん」なのか判別できないからです。

他にも黒部を示す部首に「黑 黒」のどちらを使うかという問題もあります。 これも 日本の新字体は「黒」のように囲いの内部を「十」としている。なお、表外漢字においては康熙字典に従い「黑」を用いる。 のが正解です。 あと「内」くらいの簡単な漢字でさえ「入部」と「冂部」のどちらもあり得たりして、部首そのものが適当すぎるので困ります。 そんなとき、たいていのネット辞書は文献が不明であまり信頼性がないという問題が起きるのですが、 KanjiVG は JIS漢字字典を利用しているという明確な信頼性があります。 やはり少なくともネットの部首情報よりは KanjiVG のほうが信頼できるというのが現状ではないでしょうか。

とはいえどれくらい他のデータが使えて、どのくらい安定した部首の情報を提供できるか知りたいと思いました。 そこでまずは表記ゆれのある部首漢字を 康熙字典 214 部首にする関数を作りました。 これは思ったより大変で、大量の variant をサポートする必要がありした。

部首の読みの表記ゆれ

部首の漢字に表記ゆれがあるように、部首の読みにも表記ゆれがあります。 たとえば「心 こころ したごころ」のように場所によって部首名が変わるケースがあります。 このへんを真面目にやっている辞書はなさそうなので、 KanjiVG から kvg:position="bottom" などをチェックして、正しい読みを得られるようにできるようにしたいと思いました。

偏旁 を考慮して自動で名前を付けられないかと一瞬思いましたが、例外が多すぎてたぶん無理です。 特にいい方法はないので、康熙字典 214 部首に位置情報を付けたデータを用意し、読みを返す関数を作りました。 実際に作ってみると、やはり「人部」などのコンポーネント情報だけだと、部首名を決めるのは難しいことがわかりました。 たとえば「从」は部首の位置自体は左にあるはずですが、どう見ても「亻 にんべん」ではないので、「ひと」が正しいと思います。 つまり「人 亻」をきちんと分けて登録していないと、分類できないとわかります。 KanjiVG 以外の部首データは不完全ということになります。

しかし KanjiVG もまだまだ精度には課題がありそうで、kvg:element と kvg:original が一致しないことが結構あります。 部首「士」が「土」起源とか、「儿」が「八」起源になってたり、他にも多数の間違いが見受けられるので、kvg:original は明らかに多数のバグがあるように思っています。 ただ扱いは難しくて、正しいかも?と思えるケースもあります。 たとえば「冲」ですが、これは「沖」から派生した漢字で、kvg:element「冫」の意味する氷とは関連がないので、kvg:originalが「水」になっているようです。 英語のほうが日本語より正確というね…。しかしこの登録は紛らわしいだけのような気もします。 とりあえず部首を考えるだけなら kvg:element だけを見るのが安全そうで、kvg:original は見ないほうが良さそうです。 月部と肉部の見分けくらいには使えますが、間違いが多すぎる問題があります。 難しスギィ!

CJK 漢字の部首情報

KanjiVG で変換した結果を見ていて気付いたのですが、Unicode の漢字配列は康熙字典 214 部首の順序で並んでいます。 実際に CJK 統合漢字を漢字配列だけで康熙字典 214 部首に変換している人もいました。 知らなかった…。 という訳で @marmooo/kanji に同様の機能を付けました。 以下のように使えます。
import { Kanji, Unicode1RadicalStrokes, Unicode1Radical } from "@marmooo/kanji";

const radicalStrokes = new Kanji(Unicode1RadicalStrokes);
radicalStrokes.getGrade("学"); // --> 3

const radicals = new Kanji(Unicode1Radical);
radicals.getGrade("学"); // --> 9
ちなみに CJK 統合漢字だけでなく CJK統合漢字拡張A, B も部首順で並んでいるようなので、将来的にはサポートするかも知れません。 ひとまず MJ 文字、KanjiVG、joyoKanji の康熙字典 214 部首の精度を調査すると、以下になりました
$ deno run -A check-radicals.js
check 2136 kanjis:
diff kanjivg: 33
diff joyoKanji: 56
diff mojikiban: 67
KanjiVG は頑張ればもう少し精度を挙げられそうですが、明らかな間違いもいくつかあり、実装も面倒です。 思ったより一致しないなあ…という感はありますが、たいていの漢字は一致するとわかったので、検証した甲斐はありました。 データを眺めてみた感想としても、やはり基本多言語面のほうが正確な印象があります。 一部「つき ふなづき にくづき」に違和感はあり、たとえば「服」は「にくづき」では?と思ったりしますが、「つき」で問題ないようです。 「亀」なども「乙部」として処理するかどうかで辞書の原典がわかりそうです。「黽部」は旧自体「黽」を対象としているため、新字体「亀」はたぶん含まないです。 まあこれは康熙字典に基づく考え方なので、より新しい辞書だと解釈が変わる可能性はあります。 このように基本多言語面のデータは多少の違和感があっても、実際には問題がないものばかりと思っています。 しかしその他のデータは明らかな間違いがいくつかあるので、基本多言語面に合わせるのが良い、が個人的な結論です。 また部首の解釈が今後ねじ曲がったとしても、康熙字典 214 部首の起源は変わらないので、より安定的なデータとも言えます。


真面目な部首データを作った

巷のデータは康熙字典 214 部首しかサポートしていませんが、学校ではより詳細な部首を習います。 「木部 きぶ」のような部首グループは習わず、「きへん」と部首名を習うはずですが、それを実現できるネット辞書はありません。 KanjiVG を使えばまあまあのデータが作れるのはわかりましたが、処理が面倒すぎますし、結局はどこかで手作業が必要になります。 それならいっそのこと CJK 統合漢字すべてにきちんとした部首を付けてやる!と思って、Unicode 1.1 の 20,902 文字へ部首データを付けました

一見大変そうで、実際大変ですが、思っているほど大変ではありません。 部首グループがわかっているので、事前に部首名のパターンをすべて用意しておけば、3パターンくらいから 1パターンを選ぶだけです。 部首名の情報は漢字の形を見ながらコピペしていくだけなので、1000文字くらいはサクッとアノテーションできます。 また偏→旁→繞→冠などの見分けやすい順序でアノテーションすればミスはまったく起きないので、気楽にできます。 vim だと pjpjpjpjpj… と押していくだけでアノテーションできるので、単調作業が大嫌いな私でも 1分で最低 50 個はアノテーションできました。 解釈に困らない漢字なら 2倍はいけます。

真面目にアノテーションしていると、やはりネット情報は色々な間違いらしきものが見つかるのですが、なるべく解釈がシンプルになるように作っています。 「大部」「小部」「尸部」「戈部」あたりが難しかったかな。まずは「構」かどうかを判定するのがなかなか難しいです。 あと「繞」は存在しないものが多いです 「攴部」には「ぼくにょう ぼんにょう」、「凵部」には「かんにょう」、「几部」には「きにょう つくえきにょう」「文部」には「ぶんにょう」という「繞」があると言われますが、実際には存在しません。 「夊部」の「すいにょう すいにゅう」も「処」を「すいにょう すいにゅう」判定にしないと存在しないらしいです。 でも「処」は「几部」なので要するに存在しません。部首はそれくらいいい加減な存在とも言えますね…。 他にも部首名は「てへん」のように確定的なものと、「ほこがまえ」「たすき かほのこ」のように非確定的なものがあるのが、難しいところです。


その他の部首情報

後から気付いたのですが、部首っぽいものを決定できる仕組みとして、漢字構成記述文字 (IDS: Ideographic Description Characters) があることを知りました。 IDS を使って部首を調べられないかなと思ったのですが、今のところ cjkvi-ids くらいしか方法はないみたいです。 cjkvi-ids の IDS 情報は CHISE Projet に依存しているみたいで、これはこれで面倒臭いなあという印象があります。 他にもいくつかリソースはありますが、あまり使い勝手の良いものはなさそう。 cjkvi-ids を使って部首を決定する場合、康熙字典 214 部首でブロックを分けて、IDS と一致させるような処理が必要になります。 だいたいのデータはパースできそうと思いましたが、いくつかのデータはパースできない、または一致しないことがわかっています。 部首以外のコンポーネントを使えるのは面白いのですが、結局は自作で良いのかなと思っています。

他にもIVD (Ideographic Variation Database) はうまく使えば活かせる気もしましたが、 今のところいい方法は浮かんでいません。


2023年11月30日木曜日

漢字の音訓辞書 Onkun をつよつよにした

音訓辞書 Onkun をつよつよにしました。 常用漢字の教育用の音読み・訓読みのデータは政府が 2017年、2010年に公開しており、2010年はまだ楽だったので対応していました。 2017年は小中高ごとに履修すべき項目が用意されている一方、パース不能のため放置していました。 いつかはやらないとと思っていたので手作業で分類し、送り仮名も付けて、ライブラリ化しました。 常用漢字以外も一応、対応しています。

onkun

常用漢字以外の音訓データとして、たとえば今は亡き MJ 文字 にも音読み・訓読みデータがありますが、送り仮名の情報はありません。 他にも後述するようなリソースが色々あるにはあるのですが、送り仮名をどうするかがネックになりがちです。 またリソースが分散していて面倒すぎる問題があります。 Onkun では送り仮名問題は残っているのですが、様々なリソースをまとめて、簡単に処理できるようにしました。 以下のように使えます。昔のバージョンとは互換性がないですが、まあ仕方ない。
import { Onkun } from "onkun";

const onkun = new Onkun();
await onkun.loadJoyo("data/joyo-2017.csv");
await onkun.load("Joyo", "data/joyo-2010.csv");
await onkun.load("Unihan", "data/Unihan-2023-07-15.csv");

onkun.get("漢"); // --> { 小学: ["カン"], 中学: [], 高校: [], Joyo: ["カン"], Unihan: ["カン", "タン", "から"] }
のように様々な辞書のデータを取得できるようにしました。つよつよ。

辞書について

常用漢字までの教育用途の音訓は政府が用意してあるのでそちらを使ってます。 漢検と一致しないので若干わかりにくいですが、実用上は問題ないです。 平成22年のものは joyoKanji というオープンデータがあって、たぶんこれが一番使いやすいです。 Onkun はこのデータとの整合性チェックは行っているので、登録ミスはたぶんないと思う。 常用漢字以外については Unihan Database に頼るのが良さそうですが、送り仮名はありませんし精度はそこまで信用できません。

他に音読み・訓読みを取得する手段としては KANJIDIC があります。 送り仮名や英訳が付いている時もあり利点ですが、精度はほどほどです。 13,000 字くらいしか対応しておらず、ライセンスが面倒なので、今回は見送りました。 MJ 文字 も 6000字くらいしか対応しておらず、同様の理由で見送っています。 SudachiDict は一番期待していたのですが、対応は 6,000〜7,000 と少なめです。 また実際データを生成して中身を見てみると、漢字一文字のノイズが目立つので、ちょっと使いにくそう。 網羅的すぎるが故に、熟語生成器としては良いですが、音訓生成器としては微妙です。 音訓が不明な状態からデータが難読読みか判定できる方法ってあるのかな? 以上をまとめると、対応数が 50,000 字ある Unihan Database が良さそうの結論になります。

送り仮名は難しすぎない?

音訓を手動して付与している時に気付いたのですが、「交(ま)じる」は「交(まじ)わる」のように、漢字の読み方自体が変わるのですね。 このように漢字の読み方が変化 (ま vs まじ) するのは、すごく珍しい気がします。 たまには手動でデータを弄ると気付かされることもありますね。 常用漢字でも「行う」「行なう」のように送り仮名が一意に決まらないケースもあります。 この 2つが組み合わさって「荒(あら)い」「荒(あ)らす」「荒(あら)す」のようなケースもあります。 「荒(あ)れる」みたく語尾が揺れるから仕方ないんだろうけど、「荒い」の時だけ漢字の読み方が変わるのはムズいと思う…。 まあコレを言い始めると、「明(あ)かり」と「明(あか)り」「明(あか)るい」も地味に難しいと思う。

このように送り仮名の付け方が難しい漢字があるのですが、だいたいは 内閣告示・内閣訓令 > 送り仮名の付け方 にルールが記載されているようです。 しかしこれを読ませるくらいなら、最初からまともに使えるものを配布して欲しいなあ。

たとえば「助 (すけ)」と「助 (す)け」の区別は難しいですよね。 「助(す)ける」という言葉がきちんとあるので、活用は存在すると考えられ、後者が正しいはずです。 ただ「すけ」までしか書いていないので名詞扱いだとして、「すか」のような活用がない名詞だと言い張ったとしても、例外に記述がないので後者が正しいはずです。 ただ「活用のある語から転じた」に合致するとはいえ「助(す)ける」は表外の利用法なので、これで良いのか?と迷いはあります。 ちなみにネットの辞書を調べるとすべて前者になっている状態。いちおうコトバンクは説明がありましたが、他はなし。 …といった感じで、たかが常用漢字の音訓でさえ分類が難しいのは如何なものか。 間違いの気はしつつも、自分のほうが間違っている確率のほうが圧倒的に高いので、他に合わせておきました。


難読漢字の難しさ

難読漢字の音訓データは他の辞書がどうやって入力しているのか謎なところがあります。 たとえば「龕」などを例に上げると、MJ 情報基盤も Unihan Database も「ガン カン コン」の 3つで統一されています。 しかしネットの辞書で調べると、「ずし かつ れい」などの読み方もあると書かれています。 …が、そのデータはどこから来ているのでしょうか。 「ずし」は SudachiDict にも登録されています。 「れい」は KANJIDIC に登録されていますが、「れい」で使われるのを見たことないですね。 「かつ」はまったくわからないですが…予想では漢字検定が由来なんじゃないかなあ。 これも用例が少なすぎて信頼性は確認が難しいです。 「ガン カン コン」も「ガン」以外の読み方の例を見たことがないので、その信頼性さえわかりません。

このように常用外の漢字は情報源もほとんどなくなって信頼性が大きく下がります。 辞書を作りながら言うのもアレですが、常用漢字くらいが限度で、それ以上の精度は諦めるしかないんじゃないかな…。 漢検準1級くらいまでの表外読みは SudachiDict から抽出できますが、漢字によっては地獄のような量が抽出されるので、どう扱うかは迷います。 たぶんそれらはもはや難読漢字の区切りにしたほうがよくて、表外読みは下手に色々付けないほうが良いと思う。 とりあえず付けるなら Unihan Database くらいが良いのでは。


2023年11月3日金曜日

HTML 要素の panzoom

世界地図パズル を作った時、HTML 要素の panzoom が必要になりました。 有名なものとしては以下があり、すべて検証したのですが、それぞれ欠点があることがわかりました。 もうハマりたくないのでメモを残しておきます。 上記のライブラリを遊んでみてわかったのは、 svg-pan-zoom は (1) ホイール時にページも動く、 (2) object タグの中に適用してしまうので予期しない動作が生まれる (特に y軸方向)、 anvaka/panzoom は (3) z-index が変わって表示に影響が出る、 (4) フォーカスが時々外れて違和感がある、 (5) 特定範囲内で動かす処理が指定しにくい、 (6) zoomTo() で縮小できない致命的な問題がある、 timmywil/panzoom は (7) イベントが増えると zoom がズレる、などが気になりました。 1 は親要素に overflow: hidden; を付けることで解決できるとわかったのですが、3, 4 は不明です。 7 が見つかるまでは timmywil/panzoom が一番良さそうだったのですが、 この問題が厳しかったので、結局はセルフ実装をするしかありませんでした。

Canvas の panzoom はつらい

世界地図パズル では panzoom の実装を 200行くらいで実現していますが、作るのはものすごく時間が掛かった気がします。 ちなみに何を苦労したかというと、1つは Canvas ライブラリ fabric.js との擦り合わせです。 まずは Canvas だけで実装してみようと思ったのですが、その場合 setBackgroundImage() で地図を背景画像にし、zoom を実装することになります。 しかし拡大していくと再レンダリングされない問題が発生し、ジャギーが気になりました。 小さな SVG はきちんと再描画されているので、巨大な画像だけ別処理の扱いなのかな。 issue で同じ問題が提起されており、 現状この解決がかなり難しいとわかりました。 panzoom を実装するときは SVG などを使ったほうがずっと簡単です。

とはいえ今回は fabric.js を使うのは絶対条件だったので、Canvas (ピース) と SVG (背景) で別々に panzoom を実装しました。 まずは fabric.js の zoomToPoint() や absolutePan() と timmywil/panzoom の両方を使って実装しました。 しかし座標変換の方法に差異があり擦り合わせに苦労し、また 7 の問題に直面し、セルフ実装を行うことにしました。 Canvas だと特定領域だけレンダリングをし直すようなビューが存在するので、計算が複雑になる問題があります。 これがなかなかつらい。

panzoom の実装のポイント

最後に汎用的に使えるコードをまとめておくと以下になります。 scale→translate する方式と translate→scale する方式がありますが、前者のほうが楽です。 scale を変えるときに複雑な計算がいらないのが大きいです。
let panning = false;
let zoom = 1;
let px = 0;
let py = 0;
let dx = 0;
let dy = 0;
node.addEventListener("mousewheel", (event) => {
  if (pannning) return;
  zoom += event.deltaY * -0.01;
  map.style.transform = `scale(${zoom}) translate(${dx}px,${dy}px)`;
});
node.addEventListener("mouseup", (event) => {
  if (!panning) return;
  panning = false;
  const dx += (event.clientX - px) / zoom;
  const dy += (event.clientY - py) / zoom;
});
node.addEventListener("mousedown", (event) => {
  if (panning) return;
  panning = true;
  px = event.clientX;
  py = event.clientY;
});
node.addEventListener("mousemove", (event) => {
  if (!panning) return;
  const x = event.clientX - px;
  const y = event.clientY - py;
  const tx = x / zoom + dx;
  const ty = y / zoom + dy;
  map.style.transform = `scale(${zoom}) translate(${tx}px,${ty}px)`;
});

マルチタッチがつらい

マルチタッチを考慮すると一気に実装が複雑になります。 タッチパネルを考慮して pinch zoom に対応する必要があったり、移動域を制限するなども手間です。 しかし一番厳しいのは、1点タッチが始まったとき、同時に 2点タッチが始まったとき、1点タッチの後に 2点タッチが始まったとき、 1点タッチが終わったとき、片方のタッチが終わったとき、同時に 2点タッチが終わったとき、 などかなりのパターンを考慮して実装しないといけない点です。 しかもイベントが非同期に発生するので、処理がぶつかることがあり、超大変でした。 3点タッチの実装なんて絶対に考えたくない…。

ライブラリにしようかとも思ったのですが、fabric.js などと組み合わせるとほとんどの計算過程を保持しておく必要があって、綺麗にまとめるのは難しそうでした。 また panzoom なアプリを作ることがあれば考えてみたいですが、使うことないからなー。

2023年9月15日金曜日

Deno で readLinesSync() を作った

Deno / Node.js にはファイルから非同期処理で 1行ずつ読み込む readLines() があります。 昔よりだいぶ楽になったなあと思うのですが、実行速度がすこし遅いので、同期処理で 1行ずつ読み込む readLinesSync() を作りました。

readlines-sync

Deno ならこんな感じで使えます。 Node.js / Bun ではバイナリを扱ったことがなかったので気付かなかったのですが、 dnt 経由で使うか、Uint8Array でブロックごとに読み込むコードを書く必要がありそうです。
import { readLinesSync } from "https://raw.githubusercontent.com/marmooo/readlines-sync/main/mod.js";

const file = await Deno.open("FILE");
for (const line of readLinesSync(file)) {
  console.log(line);
}
file.close();

ベンチマーク結果は以下です。最後の 3つが自作したもので、sync1 が一番最初に実装したもの、 sync2 はキャッシュのヒット率を考慮したもの、readLinesSync は sync2 を Deno / Node の API に近づけた完成物です。 Node.js の readLines() もベンチマークに入れたかったですが、まだ Deno からは使えないのでパス。 ただ node:readline と同じ速度と予想はできます。
cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
runtime: deno 1.36.4 (x86_64-unknown-linux-gnu)

bench.js
benchmark            time (avg)        iter/s             (min … max)       p75       p99      p995
--------------------------------------------------------------------- -----------------------------
node:readline          1.34 s/iter           0.7       (1.29 s … 1.44 s)    1.36 s    1.44 s    1.44 s
npm:n-readlines        3.46 s/iter           0.3       (3.42 s … 3.56 s)    3.48 s    3.56 s    3.56 s
readLines              3.45 s/iter           0.3       (3.37 s … 3.51 s)    3.48 s    3.51 s    3.51 s
TextLineStream         2.16 s/iter           0.5       (2.14 s … 2.17 s)    2.17 s    2.17 s    2.17 s
split                  1.11 s/iter           0.9        (1.1 s … 1.12 s)    1.11 s    1.12 s    1.12 s
sync1               713.78 ms/iter           1.4 (711.41 ms … 717.82 ms) 715.81 ms 717.82 ms 717.82 ms
sync2               710.79 ms/iter           1.4 (708.41 ms … 719.14 ms) 711.09 ms 719.14 ms 719.14 ms
readLinesSync          750 ms/iter           1.3 (748.78 ms … 751.63 ms) 750.44 ms 751.63 ms 751.63 ms
少なくとも Deno でよく使われる readLines() の 4倍くらい早いです。 というか readLines() が遅すぎるんだよなあ。 同期処理のほうが早いので Standard Library でも今回作ったものはぜひサポートして欲しい機能です。 そのため Third party modules に上げるべきかは微妙で、今のところ登録していません。 (とはいえ作られる気配がないので諦めるべきだろうか…?)

sync メソッドが用意されていないのは昔から疑問ですが、理由はわかっています。 readLinesSync() を真面目に作ろうとするなら普通は BufReader を改造したくなりますが、 BufReader.readline() が async、BufReader.readSlice() が async、Reader.fill() が async、Reader.read() が async になっていて、 それらすべての基底クラスを同期処理に変換しないと同期処理にできないためです。 このへんは設計的にどうなのかと感じなくもないのですが、JavaScript の async 感染的なところもあり複雑です。

BufReader 経由の実装は公式からも否定されているので、Deno/Node に依存しない Uint8Array から作ったのが、今回の代物です。 JavaScript 世界の sync/async の最適化のなさは不思議でしかないのですが、解決されることはあるのかなあ。 2023-09-22 追記: Deno 1.37.0 になって TextLineStream が高速になりました。 2023-11-24 追記: readLines が deprecated になりました。TextLineStream が今後の主流になりそうです。 それは良いのですが、現状だと TextLineStream が dnt で使えないのが難点です。 Custom Shims を書くのもちょっとなあ…。ま、そのうちなんとかなるでしょう。

2023年8月25日金曜日

ローマ字の解析ライブラリ romaji を作った

ローマ字の解析ライブラリ @marmooo/romaji を作りました。 主にタイピングの問題文として与えるひらがなをローマ字に変換したり、どこまで入力したかの確認、といった利用を想定しています。 タイピングのアプリは割とたくさん作ってきた割に、 ローマ字入力のタイピングはクソコードになっていたので、完璧にしました。

romaji

実装例

こんな感じで使えます。
import { Romaji } from "@marmooo/romaji";

const problem = "がっこう";
const romaji = new Romaji(problem);
romaji.input("g"); // --> true
romaji.input("j"); // --> false
romaji.input("a"); // --> true
romaji.inputedRomaji;   // --> "ga"
romaji.inputedHiragana; // --> "が"

globalThis.addEventListener("keydown", (event) => {
  if (romaji.input(event.key)) {
    if (romaji.isEnd()) {
      nextProblem();
    } else {
      correctType();
    }
  } else {
    incorrectType();
  }
});
ところで少し前にひらがな→ローマ字の変換のライブラリとして hiraroma を作ったのですが、仕組みは異なります。 一般的なひらがな→ローマ字の変換では最もよく使われるヘボン式さえサポートしていれば十分です。 しかしローマ字入力のタイピングではすべての変換形式に対応することが求められます。 この対応は思っているより面倒で、これまで完璧に対応しているものはなかったと思います。 最近ようやくこの手のライブラリが増えてきた気がしますが、 実装面で納得のいくライブラリはなかったので、その辺りを軽く説明します。

タイピング用のデータ構造 (基本)

ひらがな→ローマ字の変換では、ひらがな 1-3文字ごとに 1〜30パターンの入力バリエーションがあります。 100 文字にもなると、パターンがとんでもない事になって死ぬ可能性があります。 そのためすべてのバリエーションを事前生成するのは無理で、 タイピングではひらがな 1-3文字ごとのパターンを保持する必要があります。 パターンを生成する方法論としては、 DAG として捉える方法もありますが、 形態素解析の延長線でラティスとして考えるとシンプルになります。

ローマ字入力でパターンが複雑になる条件は、実はたった 1つしかありません。 前に「っ」、後ろに「ぁぃぅぇぉゃゅょ」が来た時だけです。 つまり「が|っこ|う」「や|きゅ|う」「が|っきゅ|う」となるように正規表現でセグメント分割すれば、ただの配列として管理できます。 あとはひらがなの部分文字列 (セグメント) からローマ字のパターンを生成するだけです。 たとえば「が|っこ|う」は [[ga], [kko, cco, xtuko, ltuko], [u, wu, whu]] となります。

探索時はセグメントの内部チェックをしながら、終端に到達するまで配列を遷移します。 セグメントの内部はせいぜい 30 パターン程度と事前にわかっているので、 その判定処理は配列の全件探索、Double Array、Trie、 その他様々なデータ構造のどれで実装してもたいした速度差は出ません。 その中で少しでも速度を出そうとするなら、重要なのは一文字ごとにキャッシュすることです。 探索結果をキャッシュさえしていれば一文字ごとの探索における計算量は O(1) に限りなく近づくので、より速度差はなくなります。 Trie なら 40〜50行で書けます。

以上の仕組みなら、ライブラリを拡張しようと思った時にもわかりやすいです。

特殊ルールの考慮

hiraroma のように出力をヘボン式に絞らない場合、いくつか特殊な入力方式が存在します。 このへんの対応が一番面倒なところです。 それなりに便利なルールとしては、 たとえば (1)「にんき→ninki」のような「ん○」で n を 1回省略できるルール、 (2) 「あっっ→axxtu」「あっっぽー→apppo-」のような「っ」の連続ルールがあります。

1 は普段もそれなりに使われそうですし、なにより誤爆しやすいので記憶したいルールです。 またおそらくすべての IME が標準サポートしているので考慮しました。 2 はヘボン式に直接定義はされていないと思っています。 なのでヘボン式の出力は「axtuxtu」や「axtuxtupo-」が正しいと思います。 MS-IME はこの変換をデフォルトでサポートしていませんが、他のたいていの IME はサポートしている機能です。 よく使われる「wwwww」だけ変換しない IME もありますが、ほぼデファクトの気はします。

ヘボン式そのものにもツッコミを始めると「まっちゃ→matcha」らしいです。 しかし私のキーボードだと入力できないので「maccha」でいいのではと思っています。 他にも 「てぃ→t'i」もあります。 こちらも私のキーボードだと入力できません。 このような使えないルールは紛らわしいだけなので、私は無視しています。

タイピング用のデータ構造 (応用)

「にんき→ninki」「あっっぽー→apppo-」はどのように解析すればいいでしょうか。 前者はわかりやすくて、「ん」のノードにいる時には一文字目を確定した後、次のノードの最初のアルファベットを確認する実装が必要になります。 後者も「っ」ノードにいる時には一文字目を確定した後、最初のノードと同じアルファベットを入力すると次のノードに移動できるルールの実装が必要になります。 これらは基礎編の実装を拡張すれば問題なく実装できます。

実際にはもう少し難しくて、「あ|っっぽ|ー」になるように分割した後、 「あ|っ|っ|ぽ|ー」へさらに変換して、「ぽ→p」が促音可能であることを連続する前の「っ」に知らせます。 Trie の実装は pp を「っ」としてノードに追加して skip しながら遷移する方法と、p を「っ」としてノードに追加する方法があります。 前者はひらがなの文字単位が綺麗になりますが、入力済みのローマ字を復元しようとする時に p の数が問題になります。 後者はその逆の問題として最初の p で「っ」がズレる問題があります。 ローマ字に寄せたほうがわかりやすいと思ったので後者を採用しています。 この 2つの機能についてはオプションで OFF できるようにしておきました。

ASCII 文字列の考慮

タイピングはローマ字だけでなく ASCII 文字列も含まれる可能性があります。 たとえば「あっちい!1!!!!1!」や「くぁwせdrftgyふじこlp」などがあり得るので、これもサポートしています。 ローマ字の一部と言えば一部ですしね。 ただ欠点もあって「aあAあ→aaAa」になるので元の文字列を復元できなくなる可能性があります。 記号とひらがなの組み合わせくらいにとどめたほうが良いと思います。

データ構造に関しては、ASCII 文字列を Trie で管理するのは効率が良くないですが、 どれだけ出現するかわからない ASCII 文字のために実装を増やすのもなんかなあという感じです。 速度を取るなら String と Trie を切り替えながら管理するのでしょうが、 そこまで頑張る必要もないと思うので、今のところすべて Trie Array で管理しています。 ちなみに全角英語のチェックも面倒くさいのでしていません。 ひらがな化や半角化くらいはユーザに任せて良いかなと思っています。

まとめ

あとは真面目にテストを書いてチェックすればライブラリとして完成です。 データ構造もそれなりには大切ですが、これが一番大切です。 hiraroma くらいのライブラリでさえ漏れなく作るのに苦労している状態です。 タイピング用のライブラリはより複雑なので、テストがいい加減だとさすがに使いづらいです。

速度に関しては、当たり前といえば当たり前なのですが、hiraroma のほうが圧倒的に高速です。 hiraroma だと SudachiDict を使ったフルテストは 1分で終わりますが、 romaji だと 9時間くらい掛かってしまいました。つまり 500倍くらい速度差があります。 どちらのライブラリもさらなる高速化はなかなか難しい気がするので、このへんがアルゴリズムの重要性と言えるでしょうか。 やはりタイピング以外のユースケースでは、変換をヘボン式に限定するほうが現実的と再認識しました。

jsDelivr から使いたかったので npm へ登録もしました。 ただ npm → jsDelivr への反映が 2日経っても行われておらず、何かがバグっているのかと思ってハマりました。 と思ったらトップページの検索が更新されていないだけでライブラリ自体は参照できることに気づき、猛烈に時間を無駄にしました。 次回から気を付けよう…。

タイピングゲームの実装例が見たい人はこちらもどうぞ。

2023年8月9日水曜日

Deno のコマンド実行を改めて考える

Deno でコマンド実行したいと思った時、zx を使うと簡単です。 しかし zx にもいくつか実装の選択肢があり、微妙に仕様が違って毎回迷うので、まとめておきます。

stdout, stderrr の調整

zx-denobazx は stdout, stderr の調整がしにくいので、 複雑なコマンドになるとやや使いにくいです。


コーテーションの自動挿入

dzxdax はコーテーションを自動で挿入します。
await $`sd -f m "${from}" "${to}" ${files.join(" ")}`
上記コマンドは dzx だとたぶん実行できません。 dax は以下のように書けます。
await $`sd -f m ${from} ${to} ${files}`;
これを凄いと感じるか、微妙と感じるかは人それぞれ。 私はライブラリ依存すぎると思うので、後者の立場です。ただ良いところもあります (下)。


コマンド置換

コマンド置換は bash の機能を活かした $() 記法のことです。
await $`sd -s "${from}" "${to}" $(fdfind --type file -e html .)`;
自動で使えると嬉しいですが、dzx, bazx, dax は動きません。 ただ bash の機能を使わずにスペース区切りで展開したほうが依存が少なくて良いかも知れません。 書き換えると以下になるでしょうか。ダブルコーテーションを付けるのが面倒ですね。 あと何気なく "${from}" と書いていますが、from の中にダブルコーテーションがあった時に失敗する問題があります。
const targets = await $`fdfind --type file -e js -e html .`;
const files = targets.split("\n").map((target) => `"${target}"`).join(" ");
await $`sd -s "${from}" "${to}" ${files}`;
dax ならこの処理は自動でやってくれるので、以下のように書けます。 ${from} 部分を自動でエスケープしてくれるので、ダブルコーテーションのバグが発生しにくいのも良いところです。 コマンド数が増えると楽になるかも知れません。 ミスを自動で排除できる点では dax が優れていると思います。
const files = await $`fdfind --type file -e js -e html .`.lines();
await $`sd -s ${from} ${to} ${files}`;

その他

他にも bash の機能を使えるかどうかは、大きな差になってきます。 たとえば dax だと find *.js みたいなことができません。 これは結構多くのケースで大きな課題だなと思いますし、bash 依存を断ち切るのは本当に大変だなとつくづく感じます。

まとめ

個人的な好みでは deno-dx が一番好きで利用していますが、 ミスの排除を自動化できる点では dax が優れているとは感じます。 deno-dx は実装がコンパクトなのが良いところですが、 非推奨になった Deno.run() を使っているので Deno 2.0 になったら対処が必要そうです。 Deno.Command() を使っているライブラリは dax しかないのが辛いところ。 たかがコマンド実行、されどコマンド実行…。

追記: 気付いたら本家の zx が Deno で動くようになっていて、上記の問題はだいたい解決されていました。 Deno 1.40 から deprecated warning がうるさくて大変になってきたので、本家 zx を使い始めました。

2023年7月15日土曜日

Worker Module + TensorFlow.js

Firefox 114 で Web Worker の ESM (Worker Module) がサポートされました。 この更新によって Web Worker 内でバンドルサイズの最適化がしやすくなりました。

一番嬉しいのはおそらく TensorFlow.js ではないかなと思います。 TensorFlow.js を使う時、今までは以下のように実装していました。
importScripts("https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.6.0/dist/tf.min.js");

let model;
(async () => {
  model = await tf.loadGraphModel("model/model.json");
})();

ESM 化する時は、呼び出し側で type: "module" を宣言した後、
const worker = new Worker("worker.js", { type: "module" });
Worker 側で以下のように呼び出しします。 tfjs-backend-webgl を入れないと速度が大幅に落ちます。
import * as tf from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.6.0/+esm";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl@4.6.0/+esm";
import { loadGraphModel } from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter@4.6.0/+esm";

let model;
(async () => {
  model = await loadGraphModel("model/model.json");
})();
import が面倒なので以下のようにも書けます。こちらのほうが ESM らしい。 ビルドサイズは 986KB でした。
import {
  browser,
  cast,
  div,
  expandDims,
  scalar,
  tidy,
  setBackend,
} from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.6.0/+esm";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl@4.6.0/+esm";
import { loadGraphModel } from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter@4.6.0/+esm";

let model;
(async () => {
  model = await loadGraphModel("model/model.json");
})();
注意点としては以下の書き方ではビルドサイズが小さくなりません。 いまいち理由はわかっていませんが、CPU のビルドが含まれるからかなあ。
import {
  browser,
  cast,
  div,
  expandDims,
  loadGraphModel,
  scalar,
  tidy,
} from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.6.0/+esm";

let model;
(async () => {
  model = await loadGraphModel("model/model.json");
})();


Wasm 化の例も書いてみます。 setWasmPaths() したディレクトリに wasm ファイルは設置しておく必要があります。
import * as tf from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.6.0/+esm";
import { setWasmPaths } from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.6.0/+esm";
import { loadGraphModel } from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter@4.6.0/+esm";

let model;
(async () => {
  setWasmPaths("/tegaki-abc/");
  await tf.setBackend("wasm");
  model = await loadGraphModel("model/model.json");
})();
バンドルサイズは 660KB + tfjs-backend-wasm-simd.wasm (425KB) で 1.08MB でした。 WebGL を最適化した場合とサイズはほとんど変わりません。

以下の書き方もできます。先程と同様に tfjs-core と tfjs-converter に分けないとファイルサイズは小さくなりません。
import {
  browser,
  cast,
  div,
  expandDims,
  scalar,
  tidy,
  setBackend,
} from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.6.0/+esm";
import { setWasmPaths } from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.6.0/+esm";
import { loadGraphModel } from "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter@4.6.0/+esm";

let model;
(async () => {
  setWasmPaths("/tegaki-abc/");
  await tf.setBackend("wasm");
  model = await loadGraphModel("model/model.json");
})();

小さな CNN の MNIST モデルだと推論時間は、simd-wasm = webgl >> wasm でした。 threaded-simd-wasm は面倒で調べてませんが、simd-wasm より早いでしょう。ただし cross-origin isolation の設定とデバッグが面倒な問題があります。 simd-wasm と webgl の平均的な推論速度は大差ありませんでしたが、WebGL は初期ロード時だけはシェーダの設定でやたら遅くなります。 ただ初期ロードを事前に行っておけばデメリットはほとんどなくなります。

結論としては、ESM 化は 400KB ほどファイルサイズが小さくなるのでアリだと思いますが、正直あまり旨味はない気がします。 またよほどのことがない限り Wasm より WebGL のほうが楽なので良いと思いました (2024-04-26)。 Web NN が標準化されたり、WebGPU が安定化したり、Wasm の custom build が進化したり、cross-origin isolation に進展があったかで再検討するのが良さそうです。