#きっかけと目的
マルチタッチ対応のMIDIキーボードのエミュレータを作ってみたいと考えました。
言語はマルチタッチに対応して数年は経過したJava(JavaFX)を選択してみました。
JavaはMIDI操作系のクラスも何気に古く(1.3)から存在しているので条件はそろっています。
最低、鍵盤押下で音がなるレベル。可能な限りブラッシュアップしていく予定(あくまでも)。
#JavaでMIDIを扱う
本記事は、どちらかというとJavaFX、マルチタッチ側の方を中心に取り扱っていきたいと思いますので、JavaでMIDIを操作するための詳しい内容は薄いです。
("Java MIDI"などで検索すると、先人の方たちがとても詳しく説明していますので、そちらをご確認ください)
第1回の内容で使用する主な処理は以下くらいです。
Receiver receiver = MidiSystem.getReceiver();
また、ShortMessageを使って音を鳴らす、止める、音色を変更する等のメッセージを作成します。作成されたメッセージは、Receiver#sendメソッドで実行されます。
#JavaFXでキーボードをつくる
早速作っていきますが、まずは全体の構成をおおまかに考えます。
一般的なMIDIキーボードは鍵盤部だけでなく、設定や簡易的な音に変化をつける装置などを有しています。
それらをあとづけできることを考慮して、レイアウト構成を
- VBox
- HBox
- キーボード群のレイアウト
- HBox
のような階層構造にしてみます。
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.shape.Rectangle?>
<fx:root
type="javafx.scene.layout.VBox"
prefWidth="1240.0" prefHeight="400.0"
style="-fx-background-color: #fff;"
xmlns:fx="http://javafx.com/fxml">
<children>
<Label id="commentabel"
text="HELLO! JavaFX MIDI Keyboard EMULATOR"
prefWidth="1240.0"
/>
<HBox id="KeyArea">
<children>
</children>
</HBox>
</children>
</fx:root>
(fxmlにしなかったのはIDEのエディタにfxmlを設定するのが面倒だったためだけです。)
ここのHBox配下に、鍵盤群を配置したjavafx.scene.layout.AnchorPaneを追加します。
其のメインの鍵盤は幾つかのShapeで実装できますが、わかりやすい長方形クラスのjavafx.scene.shape.Rectangleを継承し、それっぽく作ります。
継承先には発音(Note On)、消音(Note Off)、タッチイベントの位置情報から点の重なり状態を判定する(isHover)などの機能を追加したUraKeyboard、更に白鍵、黒鍵用に分けたクラスを用意します。次のような継承関係です。
javafx.scene.shape.Rectangle < UraRectangle < UraKeyboard < UraBlackKey, UraWhiteKey
音を鳴らす、止める際の処理は後述します。
##鍵の配置について(動的につくる)
さて、1つの鍵オブジェクトは配置できるようになりましたが、其の配置手段です。
JavaFXでは先程のような(f)xmlにレイアウトを記載するのが最も簡単ですが、このまま鍵を記載するのでは、記述は同じ内容の繰り返しで冗長ですし、鍵の数を増やすために、レイアウトに追加していかなければなりません。
そこで、一部のシェイプやレイアウトは直接は記載せず、プログラム内で動的に増減できるようにしてみます。
まず、レイアウトはカスタムコントローラにします。カスタムコントローラは、FXMLのルート要素を<fx:root type="javafx.scene.layout.VBox"....>....</fx:root>とすることで作成できます。すでにKeyboardMain.xmlはカスタムコントローラで記載しています。
次にJava側の記載ですが、レイアウトKeyboardMain.xmlの取り込みを含めた以下のような方法で実装します。
...
public class KeyboardMain extends VBox {
...
public KeyboardMain(Scene scene) {
if (scene == null) {
scene = new Scene(this);
}
UraLayoutUtils.layoutLoad(this, scene);
}
...
}
実際KeyboardMain.xmlの取り込みはUraLayoutUtils#layoutLoadメソッドで実装しています。
public static final void layoutLoad(final Node node, final Scene scene) {
final String simpleClassName = node.getClass().getSimpleName();
// fxmlではエディターが認識してくれないので都合上xmlにしてる
final URL fxmlURL = getURL(node, simpleClassName + ".xml");
final URL cssURL = getURL(node, simpleClassName + ".css");
final FXMLLoader loader = new FXMLLoader(fxmlURL);
loader.setRoot(node);
loader.setController(node);
try {
loader.load();
if (cssURL != null) {
node.getScene().getStylesheets().add(cssURL.toExternalForm());
}
} catch (IOException e) {
String message = String.format("fxmlファイルのロードに失敗しました。 %s.xml", simpleClassName);
throw new UraIORuntimeException(message, e);
}
}
これでレイアウトを動的に追加する準備ができました。
次に、実際に動的に配置している実装箇所です。javafx.scene.layout.AnchorPaneを継承した鍵レイアウトのクラスNotesを用意し、そのchildrenタグ配下に動的に配置していきます。
public class Notes extends AnchorPane {
...
ObservableList<Node> nodes = this.getChildren();
for (final UraWhiteKey wKey : whiteKeyList) {
wKey.parentNode(this);
nodes.add(wKey);
}
for (final UraBlackKey bKey : blackKeyList) {
bKey.parentNode(this);
nodes.add(bKey);
}
}
黒鍵が隠れないよう、白鍵を全て先に配置します。
##押下のイメージをCSSで作成する
鍵盤の描画ですが、(できるかどうか未定ですが)今後自由に拡大縮小ができても良いように、画像は使用せずにスタイルシートで設定します。近年のスタイルシートはグラデーションも細かい設定ができるようになっていて比較的簡単に綺麗に見せることができます。
.blackKey {
-fx-fill: linear-gradient(from 0% 0% to 0% 100%, black, black 30%, dimgray 87%, white 89%, black 90%);
}
.whiteKey {
-fx-fill: linear-gradient(from 0% 0% to 0% 100%, white, white 88%, dimgray 88.5%, white 89%, black 90%, gray 92%, white 98%);
}
.blackKeyOn {
-fx-fill: linear-gradient(from 0% 0% to 0% 100%, black, black 50%, dimgray 94%, white 96%, black 97%);
}
.whiteKeyOn {
-fx-fill: linear-gradient(from 0% 0% to 0% 100%, white, white 98%, dimgray 98.5%, white 99%, black 100%);
}
##タッチイベントを作成する
音を鳴らすタイミングは、対象の鍵をタッチしたタイミング、音を止めるのはタッチ中の鍵から離れたときに行いたいため、今回は各鍵クラス内のsetOnTouchPressed、setOnTouchReleasedを利用します。
###押下時に音を鳴らす
押下時はsetOnTouchPressedを利用します
this.setOnTouchPressed(touchEvent -> {
try {
// thisはUraKeyboardクラス
final UraKeyboard uraKeyboard = this;
if (uraKeyboard.isPressed() || uraKeyboard.isNoteOn()) {
return;
}
setNoteOn();
uraKeyboard.setPressed(true);
// スタイルを設定し直す
this.getStyleClass().clear();
this.getStyleClass().add("keyBase");
this.getStyleClass().add("whiteKeyOn");
} finally {
touchEvent.consume();
}
});
....
public synchronized final void setNoteOn() {
this.uraReceiver().noteOn(note, velocity);
this.noteOn = true;
}
UraReciver#noteOnの内容は以下の通り
public void noteOn(final int note, int velocity) {
try {
this.shortMessage.setMessage(
ShortMessage.NOTE_ON,
this.noteProgram.getChanel(),
note,
velocity
);
this.receiver.send(this.shortMessage, 0);
} catch (InvalidMidiDataException e) {
LOG.log("ERR 意図しないエラーが発生しました。", e);
}
}
setOnTouchPressed内でsetNoteOnを呼び出し、鍵を押下した状態にし、音を鳴らしています。
(既になっている場合は連続してメッセージが飛ばないように制限しています)
実際の音出し処理はUraReceiver.java#noteOnで行っています。
ShortMessageのNOTE_ONメッセージを作成し、Receiverで送信することで鳴らしています。
また先程のCSSで設定したスタイルをタッチしたタイミングで適用し直し、押下されたかのような表示にしています。
getStyleClass().clear()したうえでgetStyleClass().add([クラス名])で押下時のスタイルを適用しています。
###離した時に音を止める
離す場合はsetOnTouchReleasedを利用します。
this.setOnTouchReleased(touchEvent -> {
try {
final UraKeyboard uraKeyboard = this;
if (!uraKeyboard.isNoteOn()) {
return;
}
setNoteOff();
uraKeyboard.setPressed(false);
// スタイルを設定し直す
this.getStyleClass().clear();
this.getStyleClass().add("keyBase");
this.getStyleClass().add("whiteKey");
} finally {
touchEvent.consume();
}
});
....
public synchronized final void setNoteOff() {
this.uraReceiver().noteOff(note, velocity);
this.noteOn = false;
}
UraReciver#noteOffの内容は以下の通り
public void noteOff(final int note, int velocity) {
try {
this.shortMessage.setMessage(
ShortMessage.NOTE_OFF,
this.noteProgram.getChanel(),
note,
velocity
);
this.receiver.send(this.shortMessage, 0);
} catch (InvalidMidiDataException e) {
LOG.log("ERR 意図しないエラーが発生しました。", e);
}
}
今度はsetOnTouchReleased内でsetNoteOffを呼び出し、鍵を離した状態にし、音を止めています。
実際の音を止める処理はUraReceiver.java#noteOffで行っています。
こちらは、UraReceiver.java#noteOnとはメッセージが異なるだけで変わりません。
同じチャネル、同じ音階をしています。
スタイルもPressed同様に適用し直しています。押下前の状態に戻します。
#実行
紹介した内容はgithubにアップしています。
https://github.com/syany/u-board/tree/0.1.1
試してみてください。マルチタッチならではの和音も出せますよw
#懸念点
実際にお試しいただくとわかりますが、本物のキーボードとは様々な感じが異なります。
例えば、今回のソースでは本物のキーボードのようにド・レ・ミと流れるように隣の音階には移動できません。
常に画面から離してタッチし直さなければなりません。
次回以降で解決すべき課題ですね。
#次回予定
以降は次のようなことがしたいと思っています
- スライドするだけで、隣の鍵盤が押下されるように対応する
- ピッチベンドやモジュレーションを操作するリボンコントローラをつける
手探りなので、どこまでいけるかなぁ
#補足
1つ楽器(音色)について記載忘れてました。
##楽器(音色)を選択する
デフォルトの楽器の選択は、外出しの設定ファイル(application.json)で設定します。
(その他のデフォルト値についても記載しています)
....
defaultProgramList: [
"0",
"80"
],
....
今回は、チャネル別に楽器を選択できるようにしています。チャネルの数もここでわかります。
上記の記述は、0番目のチャネルに0(グランドピアノ)を、1番目のチャネルに80(シンセ・リードの矩形波)の楽器を選択しています。
アプリケーション起動前に、この数字を変更しておくと好きな楽器で演奏できるようになります。
(楽器はシーケンサに依存しJavaのデフォルトでは0~127まで選択できるはずです)
実際の楽器の設定はUraReceiver#changeProgram内で行っています。
public void changeProgram() {
try {
this.shortMessage.setMessage(
ShortMessage.PROGRAM_CHANGE,
this.noteProgram.getChanel(),
this.noteProgram.getProgram(),
0);
this.receiver.send(this.shortMessage, 0);
} catch (InvalidMidiDataException e) {
LOG.log("ERR 意図しないエラーが発生しました。", e);
}
}
PROGRAM_CHANGEのShortMessageを作成します。このメッセージでチャネル、楽器を一緒に設定しています。
あとは、Note on/off同様にReceiverから送信して設定されます。
補足でした。