前回は小節と拍から演奏する音符の位置を拍の単位で取得することができました。今度はその拍を元に開始からの時間を求めるようにします。楽曲にはテンポの設定があるため同じ位置、同じ拍であってもテンポが違えば時間がことなります。
ユースケース
ユースケースを考えます。最初0小節目からbpmが120、拍子を4/4で初めて2小節目で60に変更し、4小節目で拍子を3/4に変更するケースを考えます。小節の数え方を0ベースにすることは前回お話ししました。
bpmが120ということは1分間に120の4分音符が演奏されることになるので一つの4分音符は0.5秒です。なので0小節目は0.5×4で2秒かかります。次の一小節目の開始時間はこのため2秒目となります。2小節目はbpmが60になり4分音符の長さが1秒になります。このため二小節目の長さは1×4で4秒になります。4小節目からはテンポは変わらないのですが拍子が代わり4分音符3つ分になるため1×3で3秒になります。
小節 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
拍子 | 4 | 4 | 4 | 4 | 3 | 3 |
bpm | 120 | 120 | 60 | 60 | 60 | 60 |
1拍の時間(秒) | 0.5 | 0.5 | 1 | 1 | 1 | 1 |
時間(秒) | 0 | 2 | 4 | 8 | 12 | 15 |
設定の仕方
設定は以下のようにできるようにします。
- 最初のテンポはxとする
- x小節目のy拍目にテンポをzとする
プログラムの方では以下のように情報を取得したいと思います。
- x小節目のy拍目の開始時間はz秒
前回小節と拍から拍単位での開始位置を求めることができましたのでここでは
- 通算でx拍目の開始時間はy秒
ということがわかれば良いものとします。
実装
設定値のためのクラスを作ります。
public static class Tempo { int measure; double beat; double tempo; Double time; public Tempo(int measure, double beat, double tempo) { this.measure = measure; this.beat = beat; this.tempo = tempo; time = null; } }
ここでtimeという変数を宣言していますがこれは後で使います。各テンポ情報が設定されている拍単位の位置が秒単位で開始後何秒であるかを記憶しておくためのものです。こうしておくとあとで再計算する必要がなくなります。
このクラスを管理するマップクラスを作ります。
public class TempoMap extends TreeMap<Double, Tempo> {
ここでの鍵は拍単位での位置になります。小節と拍から拍単位の位置を求めるには前回のTimeSignが必要ですが設定はそれが出来上がる前からおこないたいのでとりあえず配列に入れておきます。
ArrayList settings;
コンストラクタは初期のテンポを指定させるようにします。
public TempoMap(double initial){ settings = new ArrayList<>(); settings.add(new TempoMap.Tempo(0, 0.0, initial)); }
設定はputメソッドを利用できるようにします。小節と拍とテンポを指定すると内部で配列に追加します。
public void put(int measure, double beat, double tempo){ settings.add(new TempoMap.Tempo(measure, beat, tempo)); }
TimeSignオブジェクトが確定したところで初めてテンポの設定をすることにします。メソッドは以下の通りです。自分自身を初期化するclearメソッドを実行します。
void updateTime(TimeSign timeSign){ clear();
次に配列にある設定値とTimeSignオブジェクトを利用して拍単位の位置を求めそれを鍵としてテンポ情報を値として自分自身に設定していきます。もし鍵の値が同じ場合は後の値で上書きされます。
for (Tempo tempo : settings) { put(timeSign.getTotalBeat(tempo.measure, tempo.beat), tempo); }
次に各テンポの設定位置を時間単位で計算します。まず以下のように初期設定をします。
Tempo currentTempo = get(0.0); double currentBeat = 0; double currentTime = 0;
テンポ情報を順に見ていきその情報が始まるときの時間を計算して設定します。ある時間でbpmを60、その後のある時間で90に設定した場合その間はテンポはリニアに変わっていきます。ちょうど中間では75になる計算です。
for (Double nextBeat : keySet()) { Tempo nextTempo = get(nextBeat); currentTime += (nextBeat - currentBeat) * 60.0 / ((nextTempo.tempo + currentTempo.tempo) / 2d); nextTempo.time = currentTime; currentTempo = nextTempo; currentBeat = nextBeat; }
次に拍単位の位置が与えられたときに開始時間を返すメソッドを作ります。
public double getTime(double totalBeat){
まず負の拍は処理中断します。
if (totalBeat < 0) { throw new IllegalArgumentException("total beat is too small:totalBeat=" + totalBeat); }
自分のいる拍の前後のテンポ情報を取得します。
double floorKey = floorKey(totalBeat); Tempo floorTempo = get(floorKey); Double ceilingKey = ceilingKey(totalBeat); Tempo ceilingTempo;
前の情報は必ず見つかりますが後の情報は設定されていない場合があります。ない場合は前の情報と同じテンポとします。
Tempo ceilingTempo; if (ceilingKey == null) { ceilingKey = totalBeat; ceilingTempo = floorTempo; } else { ceilingTempo = get(ceilingKey); }
前の位置と後の位置が同じ場合は前の情報のテンポを現在のテンポとします。それ以外の場合は前と後の情報から按分してテンポを計算します。
double tempo; if (ceilingKey == floorKey) { tempo = floorTempo.tempo; } else { tempo = floorTempo.tempo + (ceilingTempo.tempo - floorTempo.tempo) * (totalBeat - floorKey) / (ceilingKey - floorKey); }
最後に前の情報の位置とテンポ、現在の位置とテンポから前の位置から現在の位置までの時間を求めます。それに前の情報の開始時間を足せば現在の開始時間が求められます。
確認
少し作りが難しいのですがユースケース通りになるか確認します。mapで2小節のごく手前に120のbpmを指定しているのはここまで一律に120のテンポで演奏してもらうためです。これを指定しないと0小節目の初めから2小節目の初めまでテンポは120から60にスムーズに変動してしまうためです。
TimeSign ts = new TimeSign(4); ts.put(4, 3.0); TempoMap map = new TempoMap(120); map.put(2, -0.00000000001, 120); map.put(2, 0, 60); map.updateTime(ts); for(int i = 0;i <= 5;i++){ System.out.println(i + ":" + map.getTime(ts.getTotalBeat(i, 0))); }
実行します。2小節目以降に少し誤差が出ていますがおおよそ問題なくユースケース通りの時間にすることができました。
0:0.0 1:2.0 2:4.000000000001666 3:8.000000000001666 4:12.000000000001666 5:15.000000000001666
ソース
以下ソースです。
package mocha.sound; import java.util.ArrayList; import java.util.TreeMap; import mocha.sound.TempoMap.Tempo; public class TempoMap extends TreeMap<Double, Tempo> { ArrayList settings; public TempoMap(double initial) { settings = new ArrayList<>(); settings.add(new TempoMap.Tempo(0, 0.0, initial)); } public void put(int measure, double beat, double tempo) { settings.add(new TempoMap.Tempo(measure, beat, tempo)); } void updateTime(TimeSign timeSign) { clear(); for (Tempo tempo : settings) { put(timeSign.getTotalBeat(tempo.measure, tempo.beat), tempo); } Tempo currentTempo = get(0.0); double currentBeat = 0; double currentTime = 0; for (Double nextBeat : keySet()) { Tempo nextTempo = get(nextBeat); currentTime += (nextBeat - currentBeat) * 60.0 / ((nextTempo.tempo + currentTempo.tempo) / 2d); nextTempo.time = currentTime; currentTempo = nextTempo; currentBeat = nextBeat; } } public double getTime(double totalBeat) { if (totalBeat < 0) { throw new IllegalArgumentException("total beat is too small:totalBeat=" + totalBeat); } double floorKey = floorKey(totalBeat); Tempo floorTempo = get(floorKey); Double ceilingKey = ceilingKey(totalBeat); Tempo ceilingTempo; if (ceilingKey == null) { ceilingKey = totalBeat; ceilingTempo = floorTempo; } else { ceilingTempo = get(ceilingKey); } double tempo; if (ceilingKey == floorKey) { tempo = floorTempo.tempo; } else { tempo = floorTempo.tempo + (ceilingTempo.tempo - floorTempo.tempo) * (totalBeat - floorKey) / (ceilingKey - floorKey); } return floorTempo.time + (totalBeat - floorKey) * 60d / ((floorTempo.tempo + tempo) / 2d); } public static class Tempo { int measure; double beat; double tempo; Double time; public Tempo(int measure, double beat, double tempo) { this.measure = measure; this.beat = beat; this.tempo = tempo; time = null; } } public static void main(String[] arg) { TimeSign ts = new TimeSign(4); ts.put(4, 3.0); TempoMap map = new TempoMap(120); map.put(2, -0.00000000001, 120); map.put(2, 0, 60); map.updateTime(ts); for (int i = 0; i <= 5; i++) { System.out.println(i + ":" + map.getTime(ts.getTotalBeat(i, 0))); } } }
次回は今までの実装をまとめて使えるようにします。