Open7

DTM on JDK

enchanenchan

1. Javaのサウンド機能

Javaにはサウンド関連の処理を担うパッケージ javax.sound.* が存在します。AIFF, WAVなどのサンプリングデータやMIDIデータの処理に加え、ハードウェアデバイスと直接やりとりするための低レベルAPIなど、さまざまな機能が幅広く提供されています。

今回は、MIDI関連の機能を提供する javax.sound.midi で遊んでいきます。

enchanenchan

2. プロジェクトのセットアップ

はじめに、Gradleが動作する適当な環境を用意します。

  • Java: openjdk 21.0.6 2025-01-21
  • Gradle: 8.14.2

gradle を叩いてプロジェクトを作成します。

mkdir dtm-on-jdk
cd dtm-on-jdk
gradle init
対話プロンプトの入力内容
Select type of build to generate:
  1: Application
  2: Library
  3: Gradle plugin
  4: Basic (build structure only)
Enter selection (default: Application) [1..4] 1

Select implementation language:
  1: Java
  2: Kotlin
  3: Groovy
  4: Scala
  5: C++
  6: Swift
Enter selection (default: Java) [1..6] 1

Enter target Java version (min: 7, default: 21):   [Enter]

Project name (default: dtm-on-jdk): [Enter]

Select application structure:
  1: Single application project
  2: Application and library project
Enter selection (default: Single application project) [1..2] 1

Select build script DSL:
  1: Kotlin
  2: Groovy
Enter selection (default: Kotlin) [1..2] 2

Select test framework:
  1: JUnit 4
  2: TestNG
  3: Spock
  4: JUnit Jupiter
Enter selection (default: JUnit Jupiter) [1..4] 1

Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no] [Enter]

BUILD SUCCESSFUL in 2m
1 actionable task: 1 executed

適宜パッケージ名やパスを変更します。
今回は app/src/main/java/me/enchan/dtm_on_jdk/App.java にエントリポイントをおきました。

package me.enchan.dtm_on_jdk;

public class App {

    private void run() throws Exception {
        System.out.println("hello, java");
    }

    public static void main(String[] args) {
        App app = new App();
        try {
            app.run();
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(1);
        }
    }
}

環境を確認する

本書ではJDK付属のソフトウェアシンセサイザー Gervill を使用します。
ほとんどのJDKに搭載されているようですが、環境やバージョンによっては使用できない可能性があります。本格的に遊び始める前に確認しておきましょう。

以下のコードを実行します。

環境確認コード
package me.enchan.dtm_on_jdk;

import javax.sound.midi.MidiSystem;

public class App {

    private void run() throws Exception {
        final var synthesizer = MidiSystem.getSynthesizer();

        final var deviceInfo = synthesizer.getDeviceInfo();
        final var name = deviceInfo.getName();
        final var description = deviceInfo.getDescription();

        System.out.println("name       : " + name);
        System.out.println("description: " + description);
    }

    public static void main(String[] args) {
        App app = new App();
        try {
            app.run();
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(1);
        }
    }
}

Gervillが使用可能な環境では、以下のように出力されるはずです。

name       : Gervill
description: Software MIDI Synthesizer

MIDIシステムが利用できない、またはGervillが存在しない環境では MidiUnavailableException がthrowされます。

コラム: ソフトシンセとGervill

WAVやAIFFなどのサンプリング形式と異なり、MIDIデータ自体は音色のデータを持ちません。これはMIDIが「音色と演奏のデータを分けて保持する」という仕組みになっているためです。

MIDIデータは「いつ、どの音を鳴らす」「いつ、どの音を止める」といった時系列情報しか持たないため、これを実際に演奏するには音源となるデバイスやソフトウェア(= MIDI音源)が別途必要になります。

Oracle JDKにはソフトウェアシンセサイザが同梱されており、JDK 7以降ではオープンソースの Gervill がデフォルトのシンセサイザとして使用されます。

そのため、アプリケーションのMIDIシステムにこれを接続すれば、DAWや音源を別途用意することなくMIDIデータを実際に演奏して楽しむことができます。

enchanenchan

3. シンセサイザで遊ぶ

環境の確認とプロジェクトの準備が済んだところで、さっそくシンセサイザで遊んでみましょう。

単音を鳴らしてみる

はじめの一歩です。ピアノの「ラ (A3)」を鳴らしてみましょう。

シンセサイザを取得し、 open() で開きます。

final var synthesizer = MidiSystem.getSynthesizer();
synthesizer.open();

シンセサイザのレシーバを取得します。

final var receiver = synthesizer.getReceiver();

シンセサイザを動かすには、レシーバにMIDIメッセージを送信します。
ここでは、A3に対応するノート番号 69 をオンオフするメッセージを作成します。第二引数にはベロシティ(音の強さ)を指定します。

final int keyCode = 69; // A3
final int velocity = 127; // 最大値
final var noteOnMessage = new ShortMessage(ShortMessage.NOTE_ON, keyCode, velocity);
final var noteOffMessage = new ShortMessage(ShortMessage.NOTE_OFF, keyCode, velocity);

作成したメッセージをレシーバに送信します。第二引数にはタイムスタンプ(単位: us)を指定します。

receiver.send(noteOnMessage, 0);
receiver.send(noteOffMessage, 2_000_000);

再生が終わったらシンセサイザの close() を呼び、リソースを解放しましょう。

synthesizer.close();

ここまでのコードを以下に示します。
実行すると、きれいな「ラ」の音が2秒間演奏されるはずです。

単音を鳴らすコード
private void run() throws Exception {
    // シンセサイザを取得し、開く
    final var synthesizer = MidiSystem.getSynthesizer();
    synthesizer.open();

    // レシーバを取得
    final var receiver = synthesizer.getReceiver();

    // メッセージを生成
    final int keyCode = 69; // A3
    final var noteOnMessage = new ShortMessage(ShortMessage.NOTE_ON, keyCode, 127);
    final var noteOffMessage = new ShortMessage(ShortMessage.NOTE_OFF, keyCode, 127);

    // レシーバに流し込む
    receiver.send(noteOnMessage, 0);
    receiver.send(noteOffMessage, 2_000_000);

    // Enterキーが押されるまで待機する
    System.out.println("type Enter key to exit");
    final var scanner = new Scanner(System.in);
    scanner.nextLine();
    scanner.close();

    // 後処理
    synthesizer.close();
}

和音を鳴らしてみる

シンセサイザは同時に複数の音を発生することもできます。試しにコード C を鳴らしてみましょう。

レシーバを取得してもよいですが、今回は MidiChannel を使ってみます。これはそのままMIDI音源のチャンネルに相当する概念です。0~15まで16のチャンネルがあり、それぞれにドラムやピアノなどのパートを割り振って使うことができます。

Gervillが提供するチャンネルの0番を取得します。

final var channel = synthesizer.getChannels()[0];

チャンネルは Receiver よりも高級であり、 noteOn を直接呼び出すことができます。
メッセージと異なりタイムスタンプを指定していない点に注意してください。これらメソッドを呼び出した瞬間にシンセサイザは発声を開始します。

final int velocity = 127; // 最大値
channel.noteOn(60, velocity); // C3
channel.noteOn(64, velocity); // E3
channel.noteOn(67, velocity); // G3

演奏が終了したら allNotesOff を呼び出します。

channel.allNotesOff();

ここまでのコードを以下に示します。
実行すると、Cコード(C3, E3, A3)が演奏されるはずです。

Cコードを鳴らすコード
private void run() throws Exception {
    // シンセサイザを取得し、開く
    final var synthesizer = MidiSystem.getSynthesizer();
    synthesizer.open();

    // チャンネル0を取得
    final var channel = synthesizer.getChannels()[0];

    // チャンネル0の発声を開始
    final int velocity = 127; // 最大値
    channel.noteOn(60, velocity); // C3
    channel.noteOn(64, velocity); // E3
    channel.noteOn(67, velocity); // G3

    // Enterキーが押されるまで待機する
    System.out.println("type Enter key to exit");
    final var scanner = new Scanner(System.in);
    scanner.nextLine();
    scanner.close();

    // 発声を終了
    channel.allNotesOff();

    // 後処理
    synthesizer.close();
}

音色を変えてみる

GervillはGM(General MIDI, MIDIの統一規格)に準拠しているため、GMに準拠した128(+α)種類の楽器を使うことができます。音色を変更するにはプログラムチェンジと呼ばれるメッセージを送信します。

前項に引き続き、MidiChannel を使用します。メソッド programChange を呼び出すことで音色を変えることができます。

channel.programChange(25);

25番はアコースティックギターに対応します。channel.noteOn を呼び出す前に実行することで、ギターの音になります。

enchanenchan

4. シーケンサで遊ぶ

シンセサイザの使い方がわかったところで、次はシーケンサに触れてみましょう。

シーケンサとは

シーケンサとは、シーケンスと呼ばれる演奏データを再生して自動演奏を行うものです。前項ではシンセサイザに直接演奏指示を出していましたが、シーケンサを使うことでその制御を任せることができます。

javax.sound では、シーケンサとシーケンスにそれぞれクラス javax.sound.midi.Sequencer, javax.sound.midi.Sequence が提供されています。本項ではこれらを活用していきます。

シーケンスの準備

シーケンサは MidiSystem から取得できます。シンセサイザと同様、 open() で開きます。

ここで取得されるインスタンスは、デフォルトで MidiSystem.getSynthesizer で得られるものと同一のシンセサイザに接続されています。
第1引数に false を与えることで、この挙動をスキップできます。

final var sequencer = MidiSystem.getSequencer();
sequencer.open();

続いてシーケンスを作成します。コンストラクタにはシーケンサが扱う時間の種類と解像度とを指定します。

第1引数 divisionTypeSequence.PPQ または Sequence.SMPTE_* を指定します。PPQは四分音符単位の(テンポに依存する)相対的な、SMPTEはタイムコードに基づく絶対的な時間単位を表します。
第2引数 resolution には、第1引数で指定した時間単位をいくつに分割するかを指定します。

たとえば 第1引数を PPQ, 第2引数を 2 に設定すると、四分音符を2分割することになり、シーケンサの最小時間単位は八分音符ということになります。

final var sequence = new Sequence(Sequence.PPQ, 1);

トラックの作成とイベントの追加

javax.midiでは、MIDIメッセージを直接シーケンスに書き込むことはせず、トラック という概念に載せて操作します。シーケンスにトラックを追加するには createTrack() を呼び出します。

final var track = sequence.createTrack();

作成したトラックにMIDIメッセージを追加していきます。MIDIメッセージを MidiEvent でラップし、メッセージのタイミングをティック単位で指定します。

ちょっとめんどうなので、適当なヘルパ関数を用意します。

private MidiEvent createNoteOnEvent(int note, int velocity, int tick) {
    try {
        final var message = new ShortMessage(ShortMessage.NOTE_ON, note, velocity);
        final var event = new MidiEvent(message, tick);
        return event;
    } catch (Exception e) {
        return null;
    }
}

private MidiEvent createNoteOffEvent(int note, int tick) {
    try {
        final var message = new ShortMessage(ShortMessage.NOTE_OFF, note, 0);
        final var event = new MidiEvent(message, tick);
        return event;
    } catch (Exception e) {
        return null;
    }
}

イベントを詰めていきます。

// イベントを詰める
final int[] notes = { 60, 62, 64, 65, 67, 69, 71, 72 };
for (int i = 0; i < notes.length; i++) {
    track.add(createNoteOnEvent(notes[i], 127, i));
    track.add(createNoteOffEvent(notes[i], i + 1));
}

これでシーケンスの準備は完了です。

シーケンスの再生

作成したシーケンスをシーケンサに割り当てます。

sequencer.setSequence(sequence);

start() を呼び出すことで、シーケンサが動作を開始します。

sequencer.start();

シーケンサの制御

シーケンサはさまざまな制御が可能です。

テンポを設定したり……

sequencer.setTempoInBPM(120);

ループ回数を設定したりすることができます。
LOOP_CONTINUOUSLY を設定することでループマシンになります。

sequencer.setLoopCount(Sequencer.LOOP_CONTINUOUSLY);
脚注
  1. Chapter 11: Playing, Recording and Editing MIDI Sequences ↩︎

enchanenchan

5. GUIに挑戦 - ドラムマシン

さて、ここまでで javax.sound.midi の基本的な概念を整理し、その扱い方を一通り把握することができました。
ここからは、実用例として簡単なGUIアプリケーションの実装に挑戦してみましょう。

最初のステップとして、シンセサイザを直接コントロールするドラムマシンを作ってみたいと思います。

仕様

はじめに、アプリケーションの大まかな機能を整理します。

  • ドラムマシンは バスドラム, スネアドラム, シンバル をそれぞれ鳴らすことができる。
  • 画面には同じ大きさのボタンが3つ横に並んでおり、それぞれ対応する楽器の名称が表示される。
    クリックすると、ボタンに対応する楽器の音が1回再生される。
  • ドラムの音色はGeneral MIDIのチャンネル10に規定されたものから選択される。
    バスドラムは36番, スネアドラムは38番, シンバルは49番にそれぞれ対応する。
  • 音源はGervillを直接使用するものとし、外部MIDI音源との接続I/Fは提供しない。

設計

under construction...

実装

under construction...