LoginSignup
4

More than 5 years have passed since last update.

[JavaFX]ソフトウェアMIDIキーボードを作ってみる 第2回 指をスライドさせて音階を変える

Posted at

OpenningKey04.png

現実のピアノは押したまま隣の鍵へ移動できる

1回目で作成したキーボードは鍵ごとにイベントリスナを実装していました。
そのため

  • タッチしながらスライドして鍵の範囲を超えても、隣の鍵は押下状態にはならないし、元の鍵も押された状態のまま
  • 隣の鍵を押下状態にするには、常に一度指を離す必要がある

という問題が起きました。
今回は、この処理を改良して、より現実のピアノっぽく表現できるようにしていきます。
親ノードから子ノードの取得方法なども記載していきますよー

解決の流れ

順を追って解決していきます。まずは、前回作成したイベントリスナの移動からです。

TouchEvent群を親ノードへ移動する

前回鍵クラスに作成した「setOnTouchPressed」、「setOnTouchReleased」を親ノードへ思い切って移動させます。直上の親はAnchorPaneを継承したNotesレイアウトクラスですが、今後のことも考え、さらにその上位KeyboardMainレイアウトクラスへ移動させます。

ここでは、1つの鍵に1つの音階(note)やレシーバ(UraReceiver)は持たせたままにして、実際に音を鳴らす、スタイルを変化させる処理は各鍵でおこない、処理を実行するイベントリスナだけを親に移動させることにしました。

もともとxmlでなく動的に作成していたので、こういう移動は意外と容易でした。

KeyboardMain.java

public class KeyboardMain extends VBox implements UraOnTouchMovedListener, UraOnTouchReleasedListener, UraOnTouchPressedListener {
....

        this.setOnTouchReleased(touchEvent -> {
            onTouchReleasedListen(touchEvent, this);
        });
        this.setOnTouchPressed(touchEvent -> {
            onTouchPressedListen(touchEvent, this);
        });
....
 @Override
    public void onTouchReleasedListen(TouchEvent touchEvent, Node node) {
        try {
            // 現在タッチしている鍵を取得
            final Node targetNode = touchEvent.getTouchPoint().getPickResult().getIntersectedNode();
            if (targetNode == null) {
                return;
            }

            LOG.log("DBG Released Before [{}] getTouchPoint=({}), target=({})", touchEvent.getTouchPoint().getId(), touchEvent.getTouchPoint(), touchEvent.getTarget());
            if (UraKeyboard.class.isInstance(targetNode)) {
                // 鍵オブジェクト上であれば、鍵用Releasedイベントリスナを実行
                this.touchReleasedKeyboardListen(touchEvent, UraKeyboard.class.cast(targetNode));
            }
            // 全てのキャッシュがなくなった場合のみ、全キーを検索し直し、音を止める。
            this.resetKeyboardNotes(touchEvent);
        } finally {
            touchEvent.consume();
        }
    }
....
    @Override
    public void onTouchPressedListen(TouchEvent touchEvent, Node node) {
        try {
            // 現在タッチしている鍵を取得
            final Node targetNode = touchEvent.getTouchPoint().getPickResult().getIntersectedNode();
            if (targetNode == null) {
                return;
            }

            LOG.log("DBG Pressed Before [{}] getTouchPoint=({}), target=({})", touchEvent.getTouchPoint().getId(), touchEvent.getTouchPoint(), touchEvent.getTarget());
            if (UraKeyboard.class.isInstance(targetNode)) {
                // 鍵オブジェクト上であれば、鍵用Pressedイベントリスナを実行
                this.touchPressedKeyboardListen(touchEvent, UraKeyboard.class.cast(targetNode));
            }
        } finally {
            touchEvent.consume();
        }
    }

どの鍵オブジェクトが押下されているのか

イベントは上位のクラスに移動しましたが、タッチイベントが発生した場合、タッチ中の座標上にある鍵をどのように特定すればよいのでしょう。
力技ですが、座標から総当りで見つけるという方法もあります。
他の方法として、これが正解というわけではないですが、javafx.scene.input.TouchEventクラスにタッチしたイベントから対象の親~子オブジェクトを見つけてくれるメンバ変数が幾つかありました。その中から、今回はjavafx.scene.input.TouchPointから取得できるjavafx.scene.input.PickResultを使用します。PickedResultは後述しますがonTouchMovedイベントにも柔軟に対応しており、最初に押下した鍵オブジェクトから、となりの鍵オブジェクトの上部にポイントが移動した場合も移動先のオブジェクトを示してくれるいいやつです。

例えばド(60)の鍵の上をタッチします。するとKeyboardMainのOnTouchPressedイベントが実行されます。
setOnTouchPressedの引数はTouchEventで、対象ポイントのイベントオブジェクトです
中にあるTouchPointオブジェクト。さらにTouchPointから取得したPickeResultオブジェクトに、対象としたい子の鍵オブジェクトが入っています。具体的にはtouchEvent.getTouchPoint().getPickResult().getIntersectedNode()のように使用します。

後は前回同様にNoteOnするだけで対象の鍵を押下状態にし、音を鳴らせます。

KeyboarddMain.java
    /**
     * 対象キーの音を鳴らし、表示を押下中の表示にする。
     * @param uraKeyboard
     */
    protected synchronized void noteOn(UraKeyboard uraKeyboard) {
        uraKeyboard.uraReceiver().noteOn(uraKeyboard.note());
        uraKeyboard.noteOn(true);
        uraKeyboard.noteOnView();
    }

    /**
     * 対象のキーの音を消し、表示を押下していない表示にする。
     * @param uraKeyboard
     */
    protected synchronized void noteOff(UraKeyboard uraKeyboard) {
        uraKeyboard.uraReceiver().noteOff(uraKeyboard.note());
        uraKeyboard.noteOn(false);
        uraKeyboard.noteOffView();
    }
....
    /**
     * 鍵向けPressedイベント用リスナ
     * @param touchEvent
     * @param uraKeyboard
     */
    protected void touchPressedKeyboardListen(final TouchEvent touchEvent, final UraKeyboard uraKeyboard) {
     // 対象の鍵を鳴らす
        this.noteOn(touchEvent.getTouchPoint(), uraKeyboard);
    }

押下しながらの移動イベントにも対応する

次に、押下中にとなりの鍵へ移動した際に、隣の鍵を押下できるようにTouchMovedイベントを追加します。
少し動くだけでも発火するので、無関係の処理についてはできる限り早く処理を終わらせて動作が遅くならないような工夫が必要です。
例えば、PickedResultの中身が鍵オブジェクトでない場合や、押下中の鍵と同一であった場合はすぐに処理を呼び元へ戻します。

PickedResultが、別(となり)の鍵オブジェクトになったタイミングで「setOnTouchPressed」、「setOnTouchReleased」で書いた同じ内容を別(となり)の鍵オブジェクト向けに実行すればOKです。

しかし、ここで問題が起こりました。

別(となり)の鍵オブジェクトを音を鳴らした状態にすることは出来ましたが
このタイミングで同時にもともと鳴っていた鍵の音を止めた状態に戻してやらねばなりません。
TouchEventは、直前の鍵オブジェクトが何なのか、直前のTouchMovedイベントなどは取得できません。

困りましたね。

タッチ中の対象鍵を管理する

そこで、ここだけ少々無理矢理ですが、ポイント別に現在タッチされている鍵オブジェクトを管理するようにしてみました。
ConcurrentMapで管理用キャッシュマップを作成し、直前までに押されていた鍵オブジェクトを管理し

  • 押されたら追加
  • 離れたら削除
  • 移動の場合は、直前のオブジェクトを削除し移動先のオブジェクトを追加

となるよう実装しました。

鍵オブジェクト管理マップ
    /** 押下中(発音中)の鍵盤オブジェクトマップ */
    protected final ConcurrentMap<Integer, UraKeyboard> noteOnKeyCacheMap = newConcurrentHashMap(10, FACTOR.NONE);
....

        synchronized (noteOnKeyCacheMap) {
            // 念のためキャッシュマップで同期
            Integer CURRENT_TOUCH_ID = touchEvent.getTouchPoint().getId();
            final UraKeyboard oldNoteOnKey = noteOnKeyCacheMap.get(CURRENT_TOUCH_ID);
....

        if (!uraKeyboard.isNoteOn()) {
            LOG.log("DBG Pressed The event already Hover({}), ({}).", uraKeyboard, uraKeyboard);
            this.noteOn(uraKeyboard);
        }
        noteOnKeyCacheMap.putIfAbsent(touchPoint.getId(), uraKeyboard);
....

    /**
     * 対象鍵上に全てのタッチポイントがない場合は音を止める。キャッシュからも削除する。
     * @param touchPoint
     * @param uraKeyboard 対象の鍵
     */
    protected void noteOff(final TouchPoint touchPoint, final UraKeyboard uraKeyboard) {
        final Integer CURRENT_TOUCH_ID = touchPoint.getId();
        boolean isOtherKeyNoteOn = false;
        for (final Map.Entry<Integer, UraKeyboard> noteOnEntry : noteOnKeyCacheMap.entrySet()) {
            if (CURRENT_TOUCH_ID.equals(noteOnEntry.getKey())) {
                continue;
            }
            if (isOtherKeyNoteOn = uraKeyboard.equals(noteOnEntry.getValue())) {
                // 対象以外にも鍵上にタッチポイントが存在した場合は音を鳴らしたままにする。
                LOG.log("DBG XXX oldNoteOnKey in map(oldNoteOnKey=[{}] ({}), target=[{}] ({}))", CURRENT_TOUCH_ID, uraKeyboard, noteOnEntry.getKey(), noteOnEntry.getValue());
                break;
            }
        }
        if (!isOtherKeyNoteOn) {
            // 対象以外、対象の鍵の上にタッチポイントが見つからない場合は音を止める
            LOG.log("DBG Move&Pressed The event did not Hover({}), ({}).", touchPoint, uraKeyboard);
            this.noteOff(uraKeyboard);
        }
        noteOnKeyCacheMap.remove(CURRENT_TOUCH_ID);
        LOG.log("DBG Move DELETE({}), __CACHE__({}).", uraKeyboard, noteOnKeyCacheMap.entrySet());
    }

noteOffだけ特殊ですね。
これは、対象がポイントから外れていても、その他のポイントが同じ鍵上に存在した場合は音を鳴らしたままにするため、現在持っているポイントをあらっています。

まとめ

一応それっぽく流れるようなキーポードに改良することができました。
最初PickedResultを見つけるまでは、Collections.binarySearchを使って力技でとってくる手法でやっていて結構苦労しました。。。(あってよかった。。)

ソースは今回も以下に置いております
https://github.com/syany/u-board/tree/0.2.3

そういえば、いまのところjar化出来ないので拾っていってもらっても、遊べないのですね!それはいけません
次までにGradleタスクに入れなくては

次回予定

公開日は未定ですが、ピッチベンド&モジュレーションリボンを追加しようと思います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4