iOSアプリエンジニアのYuyaAboです。
https://qiita.com/advent-calendar/2017/istyle 15日目です。
乃木坂46の曲「きっかけ」の一部分を、Swift(RxSwift)と、ピアノの音ファイルを使って、演奏に似たなにかをしてみました。
きっかけとは
まず「きっかけ」についてです。
「きっかけ」とは、乃木坂46 2ndアルバム「それぞれの椅子」に収録されている曲です。
Mr. Childrenの桜井和寿さんがライブでカバーしたこともあるので、知ってる方もいるかもしれません。
とても良い曲なのですが、けっこうむかしの曲なのです。どうして今さら「きっかけ」か。
やろうと思ったきっかけ
3つくらいあります。
乃木坂46東京ドームLIVEの影響
ここ、つらつらと思いの丈を書いていたら、引くほど長くなってしまって、Qiitaに適さない内容の文章が記事のほとんどを占める形になったため、断腸の思いでだいたい5行にまとめました。
- 11/7,8に行われた、乃木坂46 真夏の全国ツアー2017 FINAL! 東京ドーム公演、にぼくは2日間とも参加した
- 年内で卒業を発表している伊藤万理華・中元日芽香にとって、メンバー全員で行うLIVEとしては最後の舞台だった
- 2日目(千秋楽)は、Wアンコールがあり、その曲が「きっかけ」だった
- このWアンコール「きっかけ」が最高のUXだった。最&高、キングオブキング、ベストオブマイライフ。年内で卒業を発表している伊藤万理華・中元日芽香の周りを他メンバーが囲む、その姿にただただ感動した
- 東京ドーム公演が終わってから今日までも、その余韻はずっと続いていて、この想いをどうにかこうにか放出したかった
RxSwiftを活用できそう
弊社アイスタイルでは、iOS/Androidアプリ開発において、アーキテクチャにMVVMやクリーンアーキテクチャに似たものを採用しています。
また、RxSwiftは、データバインディングの便利さはもちろん、iOS/Androidチーム間でRxという同じ概念を用いてコミュニケーションがとれることなどから積極的に採用していて、今ぼくが関わっているアプリでもRxSwiftが多用されています。
Rxで用いられるObservableはよく"川"と表現されます。そのとおりで、流れのある処理が得意だと感じます。ぼくはこれが音楽と親和性が高いのではないかと思い、今回試してみました。ピアノの楽譜も川に見えますし。
ピアノならなんとかなりそう
子供の頃ピアノを習っていたため、まだピアノなら、楽譜も見覚えがあるので、なんとかなりそう、と始める前は思っていました。
また、「きっかけ」であれば、ピアノだけでもある程度雰囲気を表現できそうだった、というのもあります。
準備
ピアノの音素材
フリーのサウンドファイル共有サイトfreesoundから素材を探して、それをAudacityで編集して用意しました。
結局ここが一番時間かかったかもしれない。
freesound: https://freesound.org/
Audacity: https://ja.osdn.net/projects/audacity/
「きっかけ」の楽譜
ヤマハの楽譜販売サイトから購入し、参考にしました。
RxSwift
てきとうにCarthageで導入しました。
実装
アプリケーション規模的にあんまり細分化させても疲れるだけなので、最低限ViewModelにロジックとデータをおき、ViewControllerはシンプルに。
ViewModel
import Foundation
import RxSwift
final class ViewModel {
// 音階
enum Scale {
// ト
case c4s(Int) // ド#
case d4(Int) // レ
case d4s(Int) // レ#
case e4(Int) // ミ
case f4(Int) // ファ
case f4s(Int) // ファ#
case g4(Int) // ソ
case g4s(Int) // ソ#
case a4(Int) // ラ
case a4s(Int) // ラ#
case b4(Int) // シ
case c5(Int) // ド
// rest
case r(Int) // 休符
var path: URL {
switch self {
case .c4s(let value): return "C4#_\(value)".filePath
case .d4(let value): return "D4_\(value)".filePath
case .d4s(let value): return "D4#_\(value)".filePath
case .e4(let value): return "E4_\(value)".filePath
case .g4(let value): return "G4_\(value)".filePath
case .g4s(let value): return "G4#_\(value)".filePath
case .f4(let value): return "F4_\(value)".filePath
case .f4s(let value): return "F4#_\(value)".filePath
case .a4(let value): return "A4_\(value)".filePath
case .a4s(let value): return "A4#_\(value)".filePath
case .b4(let value): return "B4_\(value)".filePath
case .c5(let value): return "C5_\(value)".filePath
case .r(let value): return "R_\(value)".filePath
}
}
}
var measure: Int = -1
var rightScaleIndex: Int = -1
var rightScaleList: [Scale] {
switch measure {
case 0: return [.d4s(2), .f4(2), .g4(2), .g4s(2), .a4s(4), .d4s(2), .f4(2) ] // けっしんのきっ
case 1: return [.g4(2), .g4s(2), .a4s(4), .a4s(4), .d4s(4), .d4(2) ] // かけはりくつ
case 2: return [.d4s(8), .c5(8) ] // では
case 3: return [.c5(2), .g4s(2), .g4s(2), .g4s(8) ] // なくて
case 4: return [.g4(2), .g4(2), .g4(2), .g4s(2), .g4(4), .f4(2), .f4(2) ] // いつだってこの
case 5: return [.f4(2), .g4(2), .g4s(4), .g4s(4), .g4(4), .f4(2) ] // むねのしょうど
case 6: return [.d4s(4), .d4s(2), .a4s(4), .d4s(2), .r(4) ] // うからは
case 7: return [.d4(4), .d4s(2), .f4(4), .r(6) ] // じまる
case 8: return [.d4s(2), .f4(2), .g4(2), .g4s(2), .a4s(4), .d4s(2), .f4(2) ] // ながされてしま
case 9: return [.g4(2), .g4s(2), .a4s(4), .a4s(4), .d4s(4), .d4(2) ] // うほどていこ
case 10: return [.d4s(8), .c5(8) ] // うし
case 11: return [.c5(2), .g4s(2), .g4s(2), .g4s(8), .r(2) ] // ながら
case 12: return [.g4(2), .g4(2), .g4(2), .g4s(2), .g4(4), .f4(2), .f4(2) ] // いきるとはせん
case 13: return [.f4(2), .g4(2), .g4s(4), .g4s(4), .g4(4), .f4(2) ] // たくしたった
case 14: return [.d4s(8), .a4s(8) ] // ひと
case 15: return [.g4s(4), .g4(2), .g4s(2), .g4s(2), .g4(2), .f4(2), .d4s(2)] // つをえらぶこと
case 16: return [.d4s(2), .f4(2), .g4(2), .g4s(2), .a4s(4), .d4s(2), .f4(2) ] // けっしんはじぶ
case 17: return [.g4(2), .g4s(2), .a4s(4), .a4s(4), .d4s(2), .d4(2) ] // んからおもっ
case 18: return [.d4s(8), .c5(8) ] // たそ
case 19: return [.c5(2), .g4s(2), .g4s(2), .g4s(8) ] // のまま
case 20: return [.r(8), .r(6), .f4(2) ] // い
case 21: return [.f4(4), .a4s(2), .a4s(8) ] // きよう
default: return []
}
}
private(set) var beat: Observable<Int>
var updateRightScale: BehaviorSubject<Int?>
var rightScaleUpdated: Observable<Int?> {
return updateRightScale
.filter { $0 != -1 }
.flatMap({ (measure) -> Observable<Int?> in
self.rightScaleIndex += 1
if measure != nil {
self.measure += 1
}
if self.rightScaleList.count <= self.rightScaleIndex {
self.rightScaleIndex = -1
}
return Observable<Int?>.just(measure)
})
.filter { _ in self.rightScaleIndex != -1 }
}
init() {
updateRightScale = BehaviorSubject<Int?>(value: -1)
beat = Observable<Int>.interval(2.1, scheduler: MainScheduler.instance).startWith(0).share(replay: 1)
}
}
enumで音階を、Associated Valueで長さ(音符の種類)を表現しています。ファイルパスはこれらの情報から組み立てやすいようにしたいので、音声ファイルの命名もいい感じにします。
Stringの.filePath
プロパティは、Stringのextensionとして生やしています。
extension String {
var filePath: URL {
return URL(fileURLWithPath: Bundle.main.path(forResource: self, ofType: "mp3")!)
}
}
Observable.interval
は指定した間隔ごとにイベントを発行してくれますが、0秒目には発行してくれないので、startWith
オペレーターで最初に送ってあげるようにします。
BehaviorSubject
は初期値を使いたいので。実装の仕方によってはPublishSubject
でもいいかもです。
rightScaleUpdated
がなんかいろいろゴニョゴニョってますが、
- intervalによるイベントの場合は、小節を次に進める
- 音階を次に進める
- 現在演奏している小節の音階がなくなったらイベントを流さない
これらのことをやってます。
ViewController
import UIKit
import RxSwift
import AVFoundation
final class ViewController: UIViewController, AVAudioPlayerDelegate {
private let bag: DisposeBag = DisposeBag()
private let viewModel: ViewModel = ViewModel()
private var rightHand: AVAudioPlayer?
override func viewDidLoad() {
super.viewDidLoad()
viewModel.beat.subscribe(onNext: { [unowned self] (measure) in
self.viewModel.updateRightScale.onNext(measure)
}).disposed(by: bag)
viewModel.rightScaleUpdated.subscribe(onNext: { [unowned self] (_) in
self.rightHand = try! AVAudioPlayer(contentsOf: self.viewModel.rightScaleList[self.viewModel.rightScaleIndex].path)
self.rightHand?.delegate = self
self.rightHand?.play()
}).disposed(by: bag)
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
if player == rightHand {
viewModel.updateRightScale.onNext(nil)
}
}
}
ViewControllerはシンプルです。
AVAudioPlayerのインスタンスはプロパティとして保持しないといけないため注意が必要です。
各小節をイメージしたbeat
には、一定間隔でeventが流れてきます。これをsubscribeして、音階の更新イベントを発火させています。音階の更新イベントが終わったらrightScaleUpdated
に流れてくるので、ViewModelに保持している音階情報を指定してAVAudioPlayerインスタンスを再代入、再生終了時をフックしたいのでdelegateを設定し、play()
で音楽を再生します。
beat
が各小節なのに対して、小節ごとの音階が配列に入っているので、再生終了時のdelegateメソッド内でも、音階の更新イベントviewModel.updateRightScale.onNext(nil)
を発火させ、音階を次に進めます。
デモその1 右手
とりあえず右手で、単音だけ。
なんかちっちゃい子供が頑張って人差し指で弾いてるみたい
デモその2 左手による演奏もつけてみた
右手の人差し指だけだと悲しいので、左手(和音)の演奏も追加してみました。途中ちょっとブチブチ音が聞こえます。
左手をつけるとそれっぽさが増した!!!
左手の実装
ほぼ右手と同じです。volumeプロパティがあるみたいなので、左手を小さめにしておきました。
import UIKit
import RxSwift
import AVFoundation
final class ViewController: UIViewController, AVAudioPlayerDelegate {
private let bag: DisposeBag = DisposeBag()
private let viewModel: ViewModel = ViewModel()
private var rightHand: AVAudioPlayer?
private var leftHand: AVAudioPlayer?
override func viewDidLoad() {
super.viewDidLoad()
viewModel.beat.subscribe(onNext: { [unowned self] (measure) in
self.viewModel.updateRightScale.onNext(measure)
self.viewModel.updateLeftScale.onNext(measure)
}).disposed(by: bag)
viewModel.rightScaleUpdated.debug().subscribe(onNext: { [unowned self] (_) in
self.rightHand = try! AVAudioPlayer(contentsOf: self.viewModel.rightScaleList[self.viewModel.rightScaleIndex].path)
self.rightHand?.delegate = self
self.rightHand?.play()
}).disposed(by: bag)
viewModel.leftScaleUpdated.debug().subscribe(onNext: { [unowned self] (_) in
self.leftHand = try! AVAudioPlayer(contentsOf: self.viewModel.leftScaleList[self.viewModel.leftScaleIndex].path)
self.leftHand?.delegate = self
self.leftHand?.volume = 0.5
self.leftHand?.play()
}).disposed(by: bag)
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
if player == rightHand {
viewModel.updateRightScale.onNext(nil)
} else if player == leftHand {
viewModel.updateLeftScale.onNext(nil)
}
}
}
所感
楽譜を書いているかのように実装することができた
これがすごくよかったです。
途中、音ファイルの用意がつらすぎて、「あれ?こんなに音編集するんだったら、これObservable<Int>.interval
なんてしなくても、そのまま曲作ってそれ流せばいいじゃん。」て思ったのですが、enum活用したりとかパス指定を工夫したおかげで、まるで楽譜を書いているかのように実装することができて、めちゃ楽しかったのです。(ViewModelのrightScaleList
の部分)
これはつまり、プログラマがコード書きながら音楽を作れるということです、そう言わせてください。
和音どうしよう問題
和音(縦に複数並んでる音符、同時に鳴らす)を表現しようとしたときに、まず簡単に2通りのアプローチを思いつきました。
- 各音符それぞれでAVAudioPlayerインスタンスを生成し、同時(?)に鳴らす
- 和音の音楽ファイルを作って、1つのAVAudioPlayerインスタンスを使って鳴らす
1を試したところ、単音のファイルそれぞれを別インスタンスで同時に(厳密に)再生することができない(?)ため、和音なのに少しずつズレてしまいました。そのため今回は2のやり方でやりましたが、改良の余地はありそうです。
不安定問題
処理速度などの影響だと思うのですが、同じ条件で実行しても、うまくいくこともあれば、小節の最後の音符が再生される前に次の小節にいったりと、実行結果が不安定でした。
それから各小節間の繋ぎ目も不自然なので、美しい「きっかけ」には程遠いなーという感じです。
改善ポイントではあるものの、このやり方だと限界がある気もしています。
ピアノの楽譜をある程度読めるようになった
そもそもABCで表すことすら知らなかったので、だいぶ勉強になりました。
そんなにRx使わなかった
こういうことってありますよね。
まとめ
結果、とりあえず聴けば「きっかけ」を演奏してるんだなってわかる程度のレベルにはなりましたが、まだまだ改善の余地だらけなので、精進して参ります。
この記事を読んだみなさんが、RxSwiftに触れる、乃木坂46を知る、そしてアイスタイルという会社に興味を持つ、そのきっかけとなれば幸いです。
明日は@hosoekさんが紡いでくれます。