1954

Thoughts, stories and ideas.

Introducing TLA+ Intellij plugin

Motivation

Nowadays, it's getting popular to use formal methods to verify complex systems e.g. distributed storages.

TLA+ is a widely-adopted formal specification language created by Leslie Lamport.

The standard way to write and verify TLA+ specification is to use official TLA+ toolbox which is built on top of Eclipse.

Though the toolbox is a full-featured and easy-to-use basically, it was bit frustrating that I cannot use code editing environment I'm familiar with.

For VS code users, there is already a great vscode-tlaplus plugin.

But I'm a fan Intellij IDEA and there seems to be no plugin exists as of now. Then why not creating it?

That's why I developed TLA+ Intellij plugin.

github.com

Features

The plugin is heavily inspired by vscode-tlaplus and it has only subset of features of vscode plugin for now.

Please refer README for the features currently implemented.

But there are several unique points in the intellij plugin.

In this article, I gonna introduce one of them, fine-grained reference resolution.

Reference resolution (i.e. Find usages / Go to definition)

In tlaplus-intellij-plugin, TLA+ files are parsed by similar grammar definition as SANY (TLA+'s syntactic analyzer) which is written in Grammar-Kit's BNF.

tlaplus-intellij-plugin/tlaplus.bnf at v0.4.6 · ocadaruma/tlaplus-intellij-plugin · GitHub

Find usage / Go to definition are implement on top of it.

It means that the correctness of the reference resolution is almost same level as SANY (as well as Toolbox).

For example:

  • Local definitions introduced by LET are visible only inside of IN block
  • Definitions prefixed by LOCAL are visible only inside the module
  • Modules can be instantiated with different name by Bar == INSTANCE Foo

This greatly helps to read complex TLA specs which often separated to multiple sub modules and require us to find the definition across multiple files.

Conclusion

Though still there are points of improvement in tlaplus-intellij-plugin, it will provide better editing experience for Intellij IDEA users.

Please try it out if you're interested, and give me your feedbacks!

Does Kafka bootstrap server have biased connections from clients?

Kafka clientは以下のようなフローでbrokerに接続します。

  1. まずbootstrap.serversに指定されたいずれかのbrokerに接続し、metadata(partitionのleader)を得る
  2. partitionのleaderに対してconnectionを開く
  3. produce/consumeを開始する

最初に最低1台に繋げればmetadataを取得できるので、通常bootstrap.serversにはクラスターの全台ではなく数台のみ指定します。

ここでもしmetadataを取得したあと最初に接続したbrokerへのコネクションを閉じる仕組みが無いと、bootstrap.serversはほとんど全clientに対するソケットを保持することになり、いくらクラスターを拡張してもclientが増えればbootstrap.serversのコネクション数がボトルネックとなってしまいます。

これを避ける仕組みはちゃんとあって、connections.max.idle.ms 過ぎると必要のないコネクションは閉じるようになってます。

kafka.apache.org

Thoughts about oslo.messaging Kafka driver

(OpenStackについては素人なので間違ったことをいろいろ書いてるかもしれない)

OpenStackはクラウド環境を構築するためのオープンソースプロジェクトで、VMなどユーザー向けの計算資源を管理するサービス(Nova)や認証を提供するサービス(Keystone)etcで構成される。

それぞれのサービス、例えばNova自体も、APIエンドポイントであるnova-apiや、VMをホストする各物理マシン上で動作するnova-computeといった複数のコンポーネントで構成される。

nova-apiからnova-computeへのVMの作成要求といった各コンポーネント間のRPCは、MQを経由することで疎結合性を担保する。

refs:

docs.openstack.org

コンポーネントからMQへのアクセスはoslo.messagingとよばれるライブラリとして抽象化されていて、特定のMQに依存しない形になっている。

oslo.messagingは、上記に述べたRPCの他に、Notificationを行うAPIも提供する。

たとえばnova-computeで特定のアクションを実行した際にnotificationをMQに送ることで、外部のモニタリングツール等がMQ経由でnova-computeの状態変化を観測する、といったユースケースが想定されているようだ。

www.openstack.org

oslo.messagingにおいて、MQごとの具体的な処理を実装するレイヤーはdriverと呼ばれる。

OpenStackのMQとして広く利用されているのはRabbitMQだが、Kafkaも利用可能のようだ。

docs.openstack.org

そしてWallaby現在、Kafka driverでサポートするのはnotificationのみで、RPCは対象外とある。

docs.openstack.org

Kafkaはstream processingに最適化されたミドルウェアなので、これは妥当な判断に見える。

(もしKafka driverでRPCをサポートしようとした場合)たとえば全nova-computeノードにmessageをブロードキャストするようなケースを考えてみる。

Kafkaでこれを実現しようとするなら、あるtopic-partitionを用意して、全nova-computeがそのpartitionをsubscribeするという形になる。

Produce/consumeはpartition leaderに対してのみ可能だが、leaderは単一のKafka brokerなので、ここがscalability limitになるのは容易に想像できる。

A pitfall in Kafka partition splitting when auto.offset.reset = latest

Kafka 2.7.0現在、consumerのauto.offset.reset configはlatestがデフォルトとなっています。

これは、consumer groupがあるpartitionをsubscribeするとき、commit済みoffsetが存在しない場合(consumerの初回デプロイ時や、offsets.retention.minutes以上の期間consumerを起動していなかった場合などが該当します)、consume開始位置をlog end offsetにセットするというものです。

通常は1) consumer 2) producerの手順でデプロイしますので、これで問題ありません。(逆の場合、consumerが起動するまでの間にproduceされたmessageは処理されないことになります)

ここで、サービスを開始してしばらくしてpartitionを途中で増やしたくなったとしましょう。

これによりkeyのpartitionへの割り当てが変わるため、一時的にkey単位のmessage localityが崩れることはよく知られていますが、consumerをauto.offset.reset = latestに設定している場合、さらに一部messageのconsumeが漏れる可能性があるという落とし穴があります。

これは以下のようにして起こります。

  1. topic-Xのpartition countを1 -> 2にsplit
  2. producerがmetadataをrefreshし、partitionが追加されたことを知る。新しく追加されたpartition-1へのproduceを開始
  3. consumerがmetadataをrefreshし、consumer rebalanceが必要であることを知る。新しく追加されたpartition-1をconsume開始
    • partition-1にcommitされたoffsetはまだ存在しないため、開始位置をlog end offsetにセットする。

2と3のどちらが先に発生するかはタイミング依存です。(metadata.max.age.msや、ちょうどmetadataが更新されるような事象(brokerが死ぬとか)が起きたとしてどちらが先に検出するか、など)

もし2 => 3の間にproducerがある程度のmessageをpartition-1にproduceしていた場合、いくつかのmessageはconsumerに届かないことになります。

Demo

さて、実際にpartition splitするタイミングでdelivery lostするかどうか試してみましょう。

github.com

これは、以下のような仕様の小さなproducer/consumer applicationです。

  1. producerは、1秒間隔でmessageをproduceする。
    • messageにはmonotonic integerをidとして与える。produce完了したら、標準出力にidを出す。
  2. consumerは、consumeしたら標準出力にidを出す。

partitionを一つだけ持ったdemo-topicというtopicを作って、アプリケーションを動かしてみます。

% ./gradlew run
[ID=0] sent. tp=demo-topic-0[offset=0]
[ID=0] received.
[ID=1] sent. tp=demo-topic-0[offset=1]
[ID=1] received.
[ID=2] sent. tp=demo-topic-0[offset=2]
[ID=2] received.
[ID=3] sent. tp=demo-topic-0[offset=3]
[ID=3] received.
[ID=4] sent. tp=demo-topic-0[offset=4]
[ID=4] received.
[ID=5] sent. tp=demo-topic-0[offset=5]
[ID=5] received.
...

messageが正しく届いている限り、このようにsentとreceiveがセットで出力されます。

さて、途中でpartitionを1 -> 2に増やしてみましょう。

...
[ID=16] sent. tp=demo-topic-1[offset=0]
[ID=17] sent. tp=demo-topic-0[offset=16]
[ID=17] received.
[ID=18] sent. tp=demo-topic-1[offset=1]
[ID=19] sent. tp=demo-topic-1[offset=2]
[ID=20] sent. tp=demo-topic-0[offset=17]
[ID=20] received.
...

ある時点からproducerはpartition-1へのproduceを開始しましたが、consumerに届いていないことがわかります。 もう少し待つと、

...
[ID=28] sent. tp=demo-topic-1[offset=5]
[ID=28] received.
[ID=29] sent. tp=demo-topic-1[offset=6]
[ID=29] received.
[ID=30] sent. tp=demo-topic-0[offset=23]
[ID=30] received.
...

offset=5からconsumeされるようになりましたが、offset=4まではdelivery lostしてしまいました。

今回の検証コードではconsumerに長いmetadata.max.age.msを設定して容易に事象が発生するようにしたのですが、たとえ同じあるいはconsumerのほうを短くしていたとしても、metadataの更新には様々な契機が存在するため、auto.offset.reset = latestで有る限り同様の事象は起こり得ます。

Conclusion

consumerのauto.offset.resetをlatestにしている場合、partitionをsplitするタイミングでdelivery lostするケースがあることを確認しました。

後からpartitionを増やす可能性があり、かつdelivery lossが許容できないようなサービスの場合はauto.offset.reset = earliestに設定しておく必要がありそうです。

histogram_quantileはどのようにquantileを計算しているか

Prometheusには、quantileを計測する方法としてSummaryとHistogramの2種類があります。

prometheus.io

上記公式documentに記載がある通り、Summaryはclient sideでquantileを計測するのに対し、Histogramではprometheus sideでqueryを打つ際にアドホックに算出します。

Summaryはqueryの際にaggregationすることはできないため、たとえば「複数台のAPIサーバーがあり、それら全体での99th percentile response latencyをモニタリングしたい」といった場合は必然的にHistogramを選択することになります。(「1台1台からreportされた99th percentileの平均」などは、それはそれでモニタリングする価値のある指標ではあるかもしれませんが、統計的に意味をもつ値ではありません)

Histogramの使い方は次のようになります。(prometheus clientを使う場合はbuilderで簡単にセットアップできると思いますが)

  • histogram bucketをどのように取るか決める。たとえば次のような具合。
    • [0, 100ms]
    • [0, 200ms]
    • [0, 300ms]
    • [0, 400ms]
    • [0, +∞]
  • event発生時、対応するbucketの値をincrementする
    • たとえばresponse latencyが256msだった場合、[0, 300ms],[0, 400ms],[0, +∞]の3つをincrementする
  • それぞれのbucketについて、le=<upper bound of bucket> というlabelをつけてcountをexportする
  • histogram_quantile関数を使ってquantileを計算する。たとえば直近10minにおける99th percentileを計算したい場合は以下のようになる。
    • histogram_quantile(0.99, sum(rate(api_latency_seconds_bucket[10m])) by (le))

How histogram_quantile calculates quantile from buckets

さて、histogram_quantileはどのようにquantileを算出しているのでしょうか。

上記公式documentには

it applies linear interpolation

とあります。

さきほどの、0から400msまで100msごとに刻んでいる例を使って図示してみます。

f:id:ocadaruma:20200812003255p:plain

直近10minで合計20回のAPIリクエストがあり、latencyの分布が以下のようになったとします。(もちろんhistogramに記録した時点で個々の点のlatencyは消滅し、実際に取れるのはbucketごとの4,8,12,...というcountだけです)

f:id:ocadaruma:20200812003305p:plain

ここで75th-percentileを計算することを考えます。これは全体を小さい方から並べたときに75%のところに位置する値ですから、今回の場合小さいほうから数えて15番目の値ということです。

f:id:ocadaruma:20200812003317p:plain

これより75th percentileが[300ms, 400ms]のどこかに位置することは分かりますが、個々の点の情報はすでに消滅してhistogramになってるため、近似するしかありません。

bucket内に一様にlatencyが分布していると仮定して線形補間すれば、75th percentile = 375msが得られます。

Prometheusのコードを見ると、まさにこのような計算を行なっていることが確認できます。

prometheus/quantile.go at v2.20.1 · prometheus/prometheus · GitHub

もちろん次のように分布が一様でない場合は近似の精度が下がることになりますが、それでもbucketの[lower bound, uppder bound]内に正確な値があることは保証できます。

f:id:ocadaruma:20200812003335p:plain

そして、histogramはただのbucketごとのcounterですので、それぞれのserverからreportされた値をaggregateして全体でのquantileを計算することも容易です。

Conclusion

  • histogram_quantileの動作を理解することで、latencyの分布によっては精度に影響が出るものの、誤差はbucketの境界で抑えられることを再確認しました。
    • たとえばlatency quantileに対してSLAを設定するような場合、SLAターゲット近くのbucketを細かく取ることで精度を上げる、といったコントロールが可能です。

KIP-392 Follower fetchingによるfetch requestの増加について

Kafka 2.4.0でfollower fetchingと呼ばれる機能が入りました。

cwiki.apache.org

これにより、consumerはleader replicaのみではなくfollower replicaからデータを読み出すことが可能となります。

Follower fetchingは、たとえばデータセンターをまたいでreplicaを配置している場合に有効で、consumerと同一DCにあるreplicaから読み出すようにすれば高コストなDC間通信を削減することができます。

Consumerに対するreplicaの割当ては、ReplicaSelector interfaceを実装することで行います。

replica.selector.class broker configurationで実装を指定でき、デフォルトはLeaderSelectorです。(無条件にleader replicaを選ぶ。2.4以前の挙動と同一)

Consumerのrack情報を利用してreplicaを決定する、RackAwareReplicaSelectorもビルトインで提供されます。

High watermark propagation

Partitionのhigh watermark(以下HW)とは、全てのin-sync replicaにreplicationが完了している最新のoffsetを指します。

Consumerが読み出すことができるのはHWまでとなります。(Leaderがもっと新しいmessageを受け取っていても、replicationが完了するまでconsumerには見えない)

もし未replicateのmessageを読み出せた場合、以下のような不整合が起こるためです。

- replication factor = 3, isr = [0, 1, 2], leader = 0
- consumer group A, Bがconsumeしている

として、

timestamp event
t0 offset 55301のmessageをproduce
t1 - consumer group Aがoffset 55301のmessageをconsume
- follower replicaおよびconsumer group Bはまだ55301をfetchしていないとする
t2 - broker 0がクラッシュ
- broker 1が新たなleaderになる

=> 55301はlostするにもかかわらずAによってだけ処理された状態となる。

したがってfollower fetchingにおいても同様に、consume可能なmessageをHWまでに制限する必要があります。

ここで、followerへのHWの伝搬はfetch responseを通して行われるため、そのままだとproduceしたmessageをconsumeするまでのlatencyに最大でfetch response latencyが加算されることになります。

どういうことかというと、

- partitionのproduction rateは低い(1 request / 1 sec)
- replication factor = 3, isr = [0, 1, 2], leader = 0
- replica.fetch.max.wait.ms = 500, replica.fetch.min.bytes = 1
- consumerはleader(0)でなく1からconsumeしている

としたとき、

timestamp (millisec) event
- broker 1, 2はleaderにfetch requestを送信済みでpurgatoryに入っており、条件の満足を待っているとする
t0 - offset 55301のmessageをproduce
- fetch条件の満足により、broker 1, 2にfetch responseが返る
- 同時に、HWが55301に更新される
t0 + 1 - broker 1, 2は次のfetch requestを送信するが、この時点では条件を満足しないためpurgatoryに入る
t0 + 501 - max.waitの経過により、fetch responseが返る。これによりHWが伝搬し、broker 1から55301をconsume可能となる

Leaderからconsumeしていたらt0の時点でmessageが取得可能だったのに対し、followerへのHWの伝搬を待つため500 millisecのlatencyが加算されます。

これに対処するため、KIP-392の実装にともない、fetch responseの完了条件に「followerの持っているHW情報がstale」であることを含むようになりました。

github.com

これにより、上記の例でいうとt0 + 1の時点で即座にbroker 1にfetch responseが返り、HWが伝搬するようになります。

KAFKA-9731

問題は、この方法だとfetch requestが倍増してしまうことです。

上記の例で、stale HWをfetch response条件に含めた時に何回のfetch requestが発行されるか数えてみましょう。

timestamp (millisec) event
t0 - offset 55301のmessageをproduce
- fetch条件の満足により、broker 1, 2にfetch responseが返る
- 同時に、HWが55301に更新される
t0 + 1 - broker 1, 2は次のfetch requestを送信する
- HWがstaleであるため即座にresponseが返る
t0 + 2 - broker 1, 2は再度fetch requestを送る
- これは条件を満足しないためblockする

一度のproduce requestに対し、2倍のfetch requestが発生していることがわかります。

issues.apache.org

この問題はKAFKA-9731として報告され、以下のように修正されました。

Conclusion

  • Kafka 2.4.0, 2.4.1では、fetch requestが大幅に増加します。(2.5.0もっぽい?)
  • Kafka 2.6.0で修正されるが、follower fetchを使う際はleaderから読む場合に対してdelivery latencyが増えます

Arduinoで電子オーボエを作る

はじめに

3月の終わりに、響け!ユーフォニアム1期〜2期, リズと青い鳥, 誓いのフィナーレを一気見して「特別になりたい」気持ちを再確認し、オーボエを始めようと思い立ちました。

「昨今みんな在宅環境に投資してるし、いいイス買うようなものだし」と謎の論理を免罪符にしてMarigaux Lemaireの中古美品を買ったはいいものの、自宅は防音では無いので気軽に音は出せず、スタジオに練習に行くわけにもいきません。

自分はトランペットを長いことやっていますが木管楽器はリコーダーを除けば初めてで、オーボエの複雑な運指は問題でした。(トランペットはボタンが3つのみで音程の規則性も明確なので運指は簡単です。対してオーボエ(Lemaire)は22個キーがあり、音域によってはほぼ運指に規則性が無く丸暗記を必要とする上、左手人差し指ではハーフオープンを含め3つの状態を管理しなくてはなりません。)

今の練習状況では「第三楽章『愛ゆえの決断』」のオーボエソロを吹けるようになるまで何年かかるかわかりません。

そこで、家で手軽に運指を練習できるソリューションとしての電子オーボエの検討を始めました。

既存製品

ざっと調べた感じでは「電子オーボエ」そのものズバリな製品は見当たりませんでした。

AKAI EWIではオーボエに似た運指に切り替えられますが、そもそもキーの数も配置も異なるため再現度には限界がありそうです。

PCキーボードにオーボエのキーをマッピングする手も考えましたが、やってもかなり無理矢理な配置になるし、N-Key Roll Overに対応したキーボードを持っていませんでした。

DIY

したがって自前で製作することにしました。

おおまかに考えて、以下のような構成で電子オーボエが作れるはずです。

以下、製作の過程を紹介していきます。

作った3DモデルやArduinoスケッチはこちらで公開しています。

github.com

ブレスコントローラー

ArduinoでUSB MIDIブレスコントローラーを作る、まさに求めていたことをやっている先例がありました。

hackaday.io

同型番のセンサー(MPVZ4006GW7U)をDigiKeyで注文し、Melodion 唄口も入手してブレッドボードで動作確認します。

以下のように直でArduinoとセンサーをつなぎ、

MPVZ4006GW7U Arduino Uno R3
Vs 5V
Gnd GND
Vout A0

スケッチを書き込んでシリアルモニターで確認します。

int intensity;
int reading;
int sensorIni;

void setup() {
    delay(200);
    Serial.begin(9600);
    sensorIni = analogRead(A0);
}

void loop() {
    reading = constrain(analogRead(A0) - sensorIni, 0, 1014 - sensorIni);
    intensity = map(reading, 0, 1014 - sensorIni, 0, 127);
    Serial.println(intensity);
}

f:id:ocadaruma:20200505102347p:plain

記事でOptionalとして言及されていたdecoupling circuitは確かに省略しても動作に問題なさそうだったので、今回は無しで行きます。

筐体設計

電子オーボエの筐体をどんな形にするか、実際のオーボエを参考に穴の位置を決めつつ、ざっくり考えていきます。

f:id:ocadaruma:20200505111541p:plain

もっと本物のオーボエのような見た目にできればかっこいいでしょうが、造形が難しそうなのでこのへんを落とし所とします。

完全に直方体だとキーを押さえるのに邪魔になるため、若干流線型にしています。

3Dモデル作成

Blenderで作っていきます。。

f:id:ocadaruma:20200505113901p:plain

ブレスコントローラーやArduinoの取り付け位置まで緻密に設計していると無限に時間がかかりそうだったので、ある程度の配置だけイメージしつつ、残りは後で糸ノコで調整する前提で出力してしまいます。

3Dモデル出力

DMM.makeを利用しました。

素材は、キャンペーンで安くなっていたのでPA12(MJF)を選択。

上部と底部の2モデル合わせて15,000円でした。サイズ大きいので結構かかりますね。。

もっと小さくパーツ分けてあとでつなぎ合わせるほうがいいのかな。そうすれば家庭用プリンターでも出力できそうです。(持ってないけど)

筐体加工

無事出力できたはいいのですが、なんと第二オクターブキーをはめる穴を開け忘れていました。。ここは糸ノコで開けます。

f:id:ocadaruma:20200505120623p:plain

また、以下はモデル出力時点ではTBDにしていたので、寸法を図りつつ加工していきます。

  • 上部と底部をナットで止めるための穴
  • Arduinoを固定するための穴
  • ブレスセンサーを配置したユニバーサル基板を固定するための穴
  • 唄口を露出させるための穴

キースイッチとPCB配置

キースイッチをはめ込んで、そこに無限の可能性 アルタナを載っけていきます。

寸法通りに出力したので当たり前といえばそうですが、きれいにハマってちょっと感動。

f:id:ocadaruma:20200505123844p:plain

キーマトリックス設計

Arduino Uno R3のGPIOは14本で、オーボエのキーは23個(今回、左手人差し指ハーフオープンを別個のキーとして実装します)あるためダイレクト配線ではピンが足りません。 したがってキーマトリックスを組みます。

23個のキーに番号を振ったのち、

f:id:ocadaruma:20200505124202p:plain

キーマトリックスへ割り当てます。data[0-4]がINPUT_PULLUP、sel[0-4]がOUTPUTモードです。

任意キーの同時押しは必須要件なのですべてのキーにダイオードも入れます。

f:id:ocadaruma:20200505125635p:plain

実装

表面実装ダイオード1N4148Wを各無限の可能性にはんだ付けして、キーマトリックスを配線していきます。。

次に、ブレスコントローラーを取り付けます。

中身は最終的にこうなります。

f:id:ocadaruma:20200505131111p:plain

上部と底部を閉じてナットを締めればハードウェアの完成です。

f:id:ocadaruma:20200505130756p:plain

スケッチ実装

ロジック的に難しいことは無いですが、運指表を見ながら音との対応づけを根気よくコードに落としていきます。。

https://github.com/ocadaruma/roboe/blob/v0.1/oboe.cpp#L645

Pitch OboeKeys::toPitch() const {
    switch (this->bitFlags()) {
        case B_FLAT_3_0:
            return { 3, PitchName::B, Accidental::FLAT };
        case B_3_0:
            return { 3, PitchName::B, Accidental::NATURAL };
        case C_4_0:
            return PITCH_MIDDLE_C;
        case C_SHARP_4_0:
        case C_SHARP_4_TRILL_0:
....

C++11準拠でスケッチを書けて便利です。

ArduinoをUSB MIDI controller化

Serial.printデバッグである程度動作確認できたら、ArduinoMoco LUFAでUSB MIDI controller化していきます。

手順はこちらに詳しいです: Arduino Moco LUFA

Putting together

一通り出来上がったので手持ちのDAWで鳴らしてみます。

。。。おおむね問題ないのですが、タンギングが効きません。(タンギングとは、舌を歯やリードに当てて息の流れを遮断し、音を止めることです)

それはそうで、ブレスコントローラーは下記のように実装していました。

  • センサーはホース内の圧力に応じて値が変化する
    • 息を吹き込むとホース内の圧力が上がる
  • 唄口 -> ホース -> センサーは密閉されており、息の通り道は無い

舌をどこに当てたところで圧力が変化しないためタンギングができないのです。

そこで、唄口に小さな穴を開けます。

f:id:ocadaruma:20200505141722p:plain

これにより、継続的に息を吹き込んでいる限り圧力がかかってセンサーが反応するが、息を遮断すると穴から息が逃げて圧力が下がるようになり、タンギングが効くようになります。

というわけで最終的にできたものを演奏した動画がこちらです。(ビブラートは音源が勝手にかけてくれている。。)

youtu.be

まとめ

これで実際オーボエの運指の練習になるのかはさておき、ほぼ想定通りのハードウェアが出来上がりました。(なおこの記事ではすべてがスムーズにいったかのように書いていますが、実際は試行錯誤やパーツ到着のリードタイムなどもあり、着手から完成まで1ヶ月くらいかかってます)

ただし改善したい点もいくつか出てきました。

  • キーの配置を実際のオーボエにもっと近づけたい
    • 特に小指キーがかなり苦しい
    • 穴の配置を斜めにしたりすればマシになりそう
  • キーキャップを取り付けたい
    • キースイッチ同士をかなりタイトに配置したので普通のキーキャップがハマらない
    • そもそも、キーボード用キースイッチよりもっと適したスイッチがあるのでは?
  • 3Dモデルの完成度を上げて、印刷後の追加工作を不要にしたい
    • 糸ノコとかドリルは極力使いたくない
  • 専用PCBで配線の手間を減らしたい
  • 唄口は、衛生面から交換を容易にしたい
    • 手軽にはずして水で洗えるとうれしい。現状は唄口とホースがきつめに噛んでるので筐体のネジ外してホースを引っ張り出して押さえながらグリグリ抜く必要がある

次に作るとしたらこのあたりが課題です。