アプリ道場 Advent Calendar 2015の9日目を担当します@anthrgrnwrldです。どうぞよろしくお願いします。
「Advent Calendarの記事、何書くかな~」なんて思いながら、年末ムード溢れる痛快通りを行ったり来たりしていたところ、突然ネタがぐわっと湧いてきました。そのアイデアは以下です。
- 複数の音楽パーツを使って1曲を完成させるようなアプリを作成
- それらの音楽パーツはそれぞれ再生可能日が指定されており、その日程までは再生することが出来ないルール (アドベントカレンダーやアドベントキャンドル風)
- 音楽パーツは4~8小節のループサウンドで、全ファイル同時再生を想定した音楽構成とそれに応じたアプリの機能を持つこと
- クリスマス感があること!(←何てったってAdvent Calendarだからね!)
- このアプリの完成までの思考の流れを追いながら、そこで得た知識をAdvent Calendarの記事として投稿
上記が決まってからは一直線。ギリッギリ期日までに完成しました。
以下のGithubリンクにプロジェクトをそのまんま上げています。
▶︎anthrgrnwrld/adventCanon
以降、完成までの思考の流れを追っていきます。
##複数の音楽ファイルを同時再生する方法
このアプリの肝は複数の音声ファイルを同時再生すること。
まずどうすれば同時再生できるか調査しました。
結果、音声再生方法として最も一般的な AVFoundation.framework
のAVAudioPlayer
クラスで実現可能であることが分かりました。
CoreAudioとかが必要だったらどうしよう...とか思っていたので一安心です。(逆に言えば今後はCoreAudioを使用しなきゃいけないようなアイデアを出していきたいところです。)
再生方法は以下のような感じ。
_Project → TARGET → General_で AVFoundation.framework
をAddすることを忘れないで下さい。
import UIKit
import AVFoundation
class ViewController: UIViewController {
super.viewDidLoad()
//playerを作成
var player1: AVAudioPlayer?
//Xcodeに放り込んだサウンドファイルのURLを作成
let fileName: String? = "sound" //対象ファイル名
let fileType:String? = "m4a" //対象ファイル拡張子
let url = NSBundle.mainBundle().URLForResource(fileName, withExtension: fileType) //urlに対象ファイルのURLが入る
if let url = url {
//リファレンス記載
//In Swift, this API is imported as an initializer and is marked with the throws keyword to indicate that it throws an error in cases of failure.
//よってdo try catchを使う必要あり
do {
player1 = try AVAudioPlayer(contentsOfURL: url)
} catch {
//プレイヤー作成失敗
fatalError("Failed to initialize a player.")
}
} else {
//urlがnilなので再生できない
fatalError("Url is nil.")
}
}
//playerのプロパティ例
player1!.volume = 0.5 //Volume値 0.0から1.0まで(とした方が無難)
player1!.numberOfLoops = 0 //Loopはしない
player1!.numberOfLoops = 1 //1回Loopする
player1!.numberOfLoops = -1 //Loopし続ける
//playerのコントロール例
player1!.prepareToPlay() //再生準備 (タイミングがシビアな時のみ)
player1!.play() //再生
player1!.pause() //一時停止
player1!.stop() //停止
複数ファイルを再生する場合には必要な数playerを作ればOK。(上記ソースでplayer2,3と作ってあげれば良い)
再生したいタイミングでplayerに対し.play()
してあげれば良いです。
(今回作成したアプリではAVAudioPlayer型の配列にして複数のPlayerを管理しています。)
で、AVAudioPlayerの第一印象。
音楽を再生する上で簡単な方法だと思うのですが、これがなかなかおもしろいクラスです。
今回のアプリでは使用しませんでしたが、rateプロパティなんかは再生速度を変更出来たり。
これだけで新しいネタが出てきそうです。
##AVAudioPlayerでハマったポイント
非常にシンプルな使い方のAVAudioPlayerですが、ここで問題が発生。
特定ファイルで、playerに対しファイルの読み込み出来ないのです。
そこで調査。再生可能なファイルと不可能なファイルを比較し色々いじくってみたところ、ファイル名に数字やアンダースコアが入っている場合にこの問題が発生することが判明。
どうやらNSURL型では使用してはいけない文字がある模様。
「じゃあファイル名を変更すればいいっか」じゃあ面白くない。
読み込み出来ないファイル名を読み込み出来る文字列へエンコードし、エンコード後の文字列にてNSURLを作成することで、この問題の解決を図りました。
使用したメソッドは_.stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLQueryAllowedCharacterSet())_
今はおまじないに近い感じで使っていますが、今後使用する毎に理解が深まっていくと思います。
以下のソースのような形で関数を作成し、ファイルを指定する際にこの関数を通すことで問題を解決させました。
/**
プロジェクト内に保存したファイル名からURLを作成する
- parameter filenameArray: NSString型の配列で通常プロジェクト内のファイル名が入っていると想定
- returns: NSURL型の配列を返す
*/
func getInstrumentFileURL(filenameArray:[NSString]) -> [NSURL] {
var encodeFileURLArray: [NSURL] = []
for (index, _) in self.filenameArray.enumerate() {
guard let encFilename = filenameArray[index].stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLQueryAllowedCharacterSet()) else {
fatalError("[L\(__LINE__)]Filename is nil.")
}
guard let fileURL = NSBundle.mainBundle().URLForResource(encFilename, withExtension: fileExtension) else {
fatalError("[L\(__LINE__)]Url is nil.")
}
encodeFileURLArray.append(fileURL)
}
return encodeFileURLArray
}
##複数の音楽ファイルに対しタイミングを合わせて同時にループ再生する方法
各Playerに紐づけられたボタンを押すことが再生のトリガとなる仕様です。
ですが、ボタンを押してすぐ再生を開始させるのではなく、ループが1周終わった時にタイミングバッチリで指定したplayerの再生開始したいと考えました。
(=ユーザーは何も考えずにボタンを押すだけで演奏タイミングが合うようにしたい)
この実現方法が今回最も苦労した部分です。
発生した問題を羅列しますと次のようなものです。
- 再生させたいタイミングでバシッと再生スタートしない
- ループの繋ぎ目に違和感を感じる
- 再生し続けるとそれぞれの演奏タイミングがズレてくる
- そもそも再生スタートしない場合がある
思考錯誤の結果、以下の方法が 一番マシ と判断し、最終的な実装としました。
- サウンドの内一つ(どれでも良い)で新たにplayerを作成し、それを基幹ループとする。
- 基幹ループplayerのVolumeは0にする。
- 基幹ループplayerのdelegateを設定する。
- 1-3まででループが1周終わるとコールバックされるようになっている。そのコールバックをトリガに再度基幹ループをplayする。 (→繰り返しを実現)
- ボタンを押したらplayerと紐づけられた状態管理用の変数を変化させる。ループが1周終わる毎にplayerの状態管理に合わせ、playerの再生・停止を変化させる。
- 再生までのラグを抑えるための策として、1サウンドファイルにつき2個ずつplayerを作成する。再生必要な場合にはループ毎に交互にそれらを再生する。また再生していない方のplayerは
.prepareToPlay
を行い再生準備しておく。
よってループ回数を指定するプロパティは今回設定していません。
ループの繋ぎ目に関しては違和感があるものの、先にも書いた通り、この方法が総合的に一番マシでした。
このセクションの肝となるソースを以下に貼っておきます。
class ViewController: UIViewController, AVAudioPlayerDelegate { //AVAudioPlayerDeledateを追加している
...
//state管理用enum
enum playingState: Int {
case stop
case wait
case play
case mute
}
var instrumentArray: [AVAudioPlayer]? = [] //同時再生するAVAudioPlayerの配列
let filenameArray: [NSString] = ["0_cello","1_violin_patternA","2_violin_patternB","3_violin_patternC","4_violin_patternD","5_violin_patternE","0_cello","1_violin_patternA","2_violin_patternB","3_violin_patternC","4_violin_patternD","5_violin_patternE","0_cello"] //サウンドファイル名の配列 各ファイル名で2つずつ
var count: Int = 0 //ループ回数が奇数回か偶数回かの判別用
let soundNumber = 6 //使用しているサウンドファイルの種類数
var playingStateArray: [playingState] = [.stop, .stop, .stop, .stop, .stop, .stop] //各AVPlayerの状態の配列
let defalutVolume : Float = 0.8
override func viewDidLoad() {
super.viewDidLoad()
//サウンド生成用のPlayer設定
let encodeFileURLArray = getInstrumentFileURL(filenameArray) //ファイルパスをURL規定(?)に則した形に変換
for (index, _) in encodeFileURLArray.enumerate() {
do {
instrumentArray?.append(try AVAudioPlayer(contentsOfURL: encodeFileURLArray[index])) //指定したサウンドファイル数分Playerを作成
instrumentArray![index].numberOfLoops = 0 //Loopはしない
instrumentArray![index].volume = defalutVolume //各Playerの再生ボリュームを基準値の0.8にする
instrumentArray![index].prepareToPlay() //再生準備(バッファ読み込み)
} catch {
fatalError("Failed to initialize a player.")
}
}
let volumeView = MPVolumeView(frame: parentVolumeView.bounds)
parentVolumeView.addSubview(volumeView)
//繰り返し長さ管理用Playerの設定
instrumentArray!.last!.delegate = self //基幹ループをdelegate設定
instrumentArray!.last!.volume = 0
instrumentArray!.last!.numberOfLoops = 0
instrumentArray!.last!.play()
//ベースとなるループ音声を再生。状態を同時に変更。
instrumentArray![0].play()
playingStateArray[0] = .play
}
/*
AVAudioPlayerのDelegate関数
指定された音声ファイルの再生が完了した場合に呼ばれる
このアプリでは基幹ループの再生完了時のみ呼ばれるように設定している
*/
func audioPlayerDidFinishPlaying(player: AVAudioPlayer, successfully flag: Bool) {
//print("\(__FUNCTION__) is called!")
if player == instrumentArray!.last! {
didfinishLoop()
}
}
/*
ループ完了後の状態管理と再生・停止の変更を行う関数
主にplayするか否かを決定する
判断材料は現在引数のplayerとそのstate
- parameter player: 状態変更、再生・停止の変更の対象となるplayer
- parameter state: playerの現在のstate
*/
func judgePlayOrNot(player: AVAudioPlayer, state: playingState) -> playingState {
var rtState: playingState = .stop
switch state {
case .stop:
rtState = .stop
case .wait:
player.play()
rtState = .play
case .play:
player.volume = defalutVolume
player.play()
rtState = .play
case .mute:
player.volume = 0
player.play()
rtState = .mute
}
return rtState
}
/*
基幹ループ完了時に行う一連の処理
*/
func didfinishLoop() {
//print("\(__FUNCTION__) is called!")
count++
if count % 2 != 0 {
for var i = 0; i < soundNumber; i++ {
instrumentArray![i].stop()
let rtState = judgePlayOrNot(instrumentArray![i + soundNumber], state: playingStateArray[i])
playingStateArray[i] = rtState
if i != 0 {
setIconBackgroudColorByLoopEnd(i)
}
}
} else {
for var i = 0; i < soundNumber; i++ {
instrumentArray![i + soundNumber].stop()
let rtState = judgePlayOrNot(instrumentArray![i], state: playingStateArray[i])
playingStateArray[i] = rtState
if i != 0 {
setIconBackgroudColorByLoopEnd(i)
}
}
}
instrumentArray!.last!.play()
}
}
##指定した日時まで再生させない方法
日時と言えばNSDate。
実はNSDateも初めて使いました。
判断の仕組みとしては以下のような感じです。
- 予めNSDateFormatterで設定する記載方法に準じた形で、各playerの再生可能な日リスト(Array型)をStringで持っておきます。
- 再生可否の判断のところでこれらStringをNSDateに変換します。
- 現在日時とNSDateへ変換したリストをcompareというメソッドで比較し、再生可否を判断します。
(compareというメソッドはXcodeを弄っていて見つけただけで、文献などで調べれていません。使ってみたら動いたというレベル...。今後詳細調べないと)
このセクションは結構やっつけになってしましました...。
let dateFormatter = NSDateFormatter()
var canPlayArray: [Bool] = [false, false, false, false, false]
let strOpenDateArray: [String] = ["2015/11/29", "2015/12/06", "2015/12/13", "2015/12/20", "2015/12/24"] //アドベントカノン再生可能日管理用配列 本番用
/*
現在の日時を取得し再生可能・不可能なサウンドを選別する。
日時についてのロケールは日本としてます。
*/
func getCurrectDate() {
var openDateArray: [NSDate] = [] //サウンドを再生可能な日程を入れるNSDate型の配列
let now = NSDate() //現在日時を取得
let dateFormatter = NSDateFormatter()
dateFormatter.locale = NSLocale(localeIdentifier: "ja_JP") //地域の設定
dateFormatter.timeStyle = .NoStyle //時刻非表示
dateFormatter.dateStyle = .ShortStyle //短い形式で日付を表示
print("\(dateFormatter.stringFromDate(now))")
//現在日時と各サウンドの再生可能日程を比較
//再生可能な場合には再生可否管理用配列canPlayArrayをtrueにする
for var i = 0; i < strOpenDateArray.count; i++ {
openDateArray.append(dateFormatter.dateFromString(strOpenDateArray[i])!)
let rawValue = now.compare(openDateArray[i]).rawValue
if rawValue < 0 {
canPlayArray[i] = false
} else {
canPlayArray[i] = true
}
}
}
因みに strOpenDateArray のメンバを全て 本日 にすると、テスト用ですが全てのplayerが再生出来ます。
もしgithubでソースをダウンロードされて全部の音楽を確認してみたい方は試してみて下さい。
またiPhoneの日時を2015/12/24以降に設定した場合も全player再生可能です。
##再生状態によってボタンのバックのViewを変化させる
ボタンを押してから、音が再生されるまで待ち時間があります(再生待機の状態)。
その際、ボタンのバックに作成したViewを点滅させ、待機状態であることを明示化しようと考えました。
点滅方法は以下になります。
/**
指定されたViewを1秒間隔で点滅させる
:param: view:点滅させるView
*/
func blinkAnimationWithView(view :UIView) {
UIView.animateWithDuration(1.0, delay: 0.0, options: UIViewAnimationOptions.Repeat, animations: { () -> Void in
view.alpha = 0
}, completion: nil)
}
/**
指定されたViewの点滅アニメーションを終了する
:param: view:点滅を終了するView
*/
func finishBlinkAnimationWithView(view :UIView) {
UIView.setAnimationBeginsFromCurrentState(true)
UIView.animateWithDuration(0.001, animations: {
view.alpha = 1.0
})
// //こっちの方法でもOK
// view.layer.removeAllAnimations()
// view.alpha = 1.0
}
無限の繰り返しのアニメーションの場合、正規のアニメーションストップの方法はないみたいです。
しかし、上記 finishBlinkAnimationWithView
のように極端に短いdurationのアニメーションを実行することでストップすることができます。
詳細は手前味噌ですが、以下の小生のブログを参照のこと。
▶︎Viewの点滅を繰り返す方法とその終了方法 [swift1.2] [animateWithDuration] - MILLEN BOX
また日時的に再生不可となったplayerのボタンのバックのViewをグレーにする処理もしています。
##再生不可のボタンを押した時にAlertを表示する
押されたボタンに紐付けされたplayerが日程的に再生不可の場合、Alertを表示するようにしました。
上記条件に合致する場合、以下の関数を実行してAlertを表示するようにします。
//多言語化対応
let alertTitle:String = NSLocalizedString("alertTitle", comment: "アラートのタイトル")
let alertMessage1:String = NSLocalizedString("alertMessage1", comment: "アラートのメッセージ1")
let alertMessage2:String = NSLocalizedString("alertMessage2", comment: "アラートのメッセージ2")
let actionTitle = "OK"
func showAlert(strDate: String) {
// Style Alert
let alert: UIAlertController = UIAlertController(
title:alertTitle,
message: alertMessage1 + strDate + alertMessage2,
preferredStyle: UIAlertControllerStyle.Alert
)
// Default 複数指定可
let defaultAction: UIAlertAction = UIAlertAction(
title: "OK",
style: UIAlertActionStyle.Default,
handler:{
(action:UIAlertAction!) -> Void in
print("OK")
})
// AddAction 記述順に反映される
alert.addAction(defaultAction)
// Display
presentViewController(alert, animated: true, completion: nil)
}
##MPVolumeViewでVolumeBarの表示
このアプリ、iPhoneをMuteされちゃってた場合、何にもおもしろくないアプリになります。(なぜなら音が出ないから)
よってMute/UnMuteを起動時に判断してMuteされている場合にはUnMuteを促すようなAlert表示してみようかとざっくり調査しましたが、意外にこれが難しそう。
代替案として画面下部にVolumeBarViewを表示させることで、「音が鳴るアプリ」であることを明示的に示すようにしました。
_Project → TARGET → General_で MediaPlayer.framework
をAddすることを忘れないで下さい。
因みにSimulatorでは表示されません。
import MediaPlayer
override func viewDidLoad() {
super.viewDidLoad()
...
//(Simulator不可)VolumeViewを表示する
let volumeView = MPVolumeView(frame: parentVolumeView.bounds) //parentVolumeViewはSotryBoardで設定したもの
parentVolumeView.addSubview(volumeView)
}
##完成!
以上のような流れで完成に至りました。
以下、感想です。
AVAudioPlayerについて初めて触ってみました。
リファレンスを読んでいると非常に分かりやすくスッと入ってきたのですが、(タイミングなど)思った通り動かないことが多く、考えさせられることが多かったです。
今回のようなアプリの場合、もっと適したクラスがあるのかも!よし調べてみよ~!と思いました。
また将来的に楽器系のアプリを作成したいため、その取っ掛かりにはなったなと感じています。
##(おまけ)音素材作成
曲はパッヘルベルのカノンを使用しています。
耳残りの良いフレーズをパズルみたいに組み合わせてパーツをアレンジしました。
(実はアレンジに一番時間が掛かってたり...。)
パーツ作成に使用したツールはGarageBand!凝ったことしないんだったらほんとにこれが簡単です。
##最後に...
そしてこのアプリ、タイトルにもあります通り、さりげなーく本日サブミットしました!
上手くいけば5~7営業日くらいで App Storeに並ぶと思います。
以下のURLのはずです。
▶︎Advent Canon 2015 - 音楽と共にクリスマスを祝おう
###それでは皆様、良いクリスマスを!!