iPhoneのマイクだけでハウリングしやすい帯域を表示するアプリを作っています。
以前から、リハーサルスタジオやライブでの演奏をきれいに録音できるアプリを探していましたが、これといったものが見つかりませんでした。
多くの録音アプリは、大音量になるとリミッターがかかるため、その場の演奏が忠実に残っているのか確認しにくいものでした。また、リミッターのないアプリでも、入力ゲインを下げなければ音が割れるので、小さく録っておいて自宅のDAWで音量を調節するという二度手間になっていました。
そこで、その場の音をそのまま録音でき、しかも入力ゲインの設定が分かりやすいものを、自分で作ることにしました。
現在、録音した音の音量をそろえる機能を作っています。録音済みのファイルのラウドネスを測り、あらかじめ決めた大きさに合わせてゲインをかけ、別名で書き出す処理です。
ラウドネスは、音の大きさを人の聞こえ方に合わせて表したもので、単位には LUFS を使います。目標は -16 LUFS にしました。
テスト用ファイルは32秒で、何度動かしても一瞬で終わっていました。しかしライブ1本にあたる2時間の録音を想定していたため、メモリの使用量を測り直したところ、60分で 6.92 GB、120分では 12.5〜13.0 GB を使っていました。
この記事は、そのとき書き直したコードと、その前後の実測値の記録です。
書いていたコード
まず、書き直す前のコードをそのまま載せます。
public static func 測る(ファイル url: URL) -> 結果? {
guard let f = try? AVAudioFile(forReading: url) else { return nil }
let fmt = f.processingFormat
guard let buf = AVAudioPCMBuffer(pcmFormat: fmt,
frameCapacity: AVAudioFrameCount(f.length)) else { return nil }
do { try f.read(into: buf) } catch { return nil }
guard let d = buf.floatChannelData else { return nil }
let n = Int(buf.frameLength), c = Int(fmt.channelCount)
var chs: [[Double]] = []
for i in 0..<c {
var a = [Double](repeating: 0, count: n)
for k in 0..<n { a[k] = Double(d[i][k]) }
chs.append(a)
}
return 測る(チャンネル: chs, サンプリング: fmt.sampleRate)
}
特別なことはしていません。frameCapacity にファイルの長さを渡してバッファを作り、read(into:) で読むだけです。AVAudioFile の使い方として、最初に出てくる形そのままです。
書き出す側も同じでした。
static func 書き出す(元 url: URL, 倍率: Double) -> URL? {
guard let f = try? AVAudioFile(forReading: url) else { return nil }
let fmt = f.processingFormat
guard let buf = AVAudioPCMBuffer(pcmFormat: fmt,
frameCapacity: AVAudioFrameCount(f.length)),
(try? f.read(into: buf)) != nil, let d = buf.floatChannelData else { return nil }
let g = Float(倍率)
for c in 0..<Int(fmt.channelCount) {
for i in 0..<Int(buf.frameLength) { d[c][i] *= g }
}
// …以下、書き出しの処理が続く
}
10分・60分・120分で実測する
/usr/bin/time -l の maximum resident set size を見ます。
/usr/bin/time -l <ビルドしたもの> <音のファイル> 2>&1 | grep "maximum resident"
| 録音の長さ | 測るだけ | 測る+書き出す |
|---|---|---|
| 10分 | 1.16 GB | 1.16 GB |
| 60分 | 6.92 GB | 6.92 GB |
| 120分 | 12.5〜13.0 GB | 13.50 GB |
単位は GB(1 GB = 1,000,000,000 バイト)です。3回とも 6.92 GB でした(生の値は 6,920,486,912/6,920,601,600/6,920,716,288 バイト)。
120分だけ、測るたびに 12.5〜13.0 GB の幅で動きました。10分と60分は、丸めるとどの回も同じ値でした。
測ったのは Mac(-O ビルド)です。iPhoneで落ちるところを見たわけではありません。
ただ、60分で 6.92 GB を使う作りのまま、iPhoneで2時間録音させるつもりでいました。
同じ音を、3つの形で同時に持っていた
原因は単純で、計測側の関数が同じ音を3つの形で同時に保持していました。
-
buf… ファイルから読んだ全部(Float32) -
chs… それを Double に写した全部 - フィルタを通した後のもの(Double)
48kHz・ステレオ・2時間なら、サンプル数は 48,000 × 2 × 7,200 = 691,200,000 個です。
Float32 が1つで 2.76 GB、Double が2つで 5.53 GB × 2 = 11.06 GB。合わせて 13.82 GB になり、実際に計測した 13.50 GB とほぼ一致します。
しかし、これらを同時にすべて保持する必要はありませんでした。
LUFSは区切って足しても同じ値になる
ラウドネスの測り方は ITU-R BS.1770 で定められています。原文にこうあります。
A gating block is a set of contiguous audio samples of duration Tg = 400 ms, to the nearest sample.
The overlap of each gating block shall be 75% of the gating block duration.
(ITU-R BS.1770-5)
400msのブロックを、75%ずつ重ねて並べる。重なりが75%ということは、ブロックの刻みは100msです。
つまり、100msごとの二乗和さえ保持していれば、4つ足し合わせることで400msのブロックが得られます。音声データそのものを最後まで保持する必要はありません。
ただし、Kフィルタは直前のサンプルを使うため、フィルタの状態と真のピーク用の直近サンプルだけは、読み込みの区切りをまたいで持ち越します。ここを区切りごとに初期化すると、値が変わります。
読み込みの間ずっと保持し続けるのは、これだけです。
100msごとの二乗和 × チャンネル数
2時間・ステレオ = 72,000 × 2 × 8バイト = 約1.2MB
計算結果は1つも変わりません。区切って足しても、まとめて足しても同じ値になります。
直したコード
1秒分のバッファを1つだけ用意し、読み込みのたびに使い回します。
let 一度に = AVAudioFrameCount(max(step, Int(fs))) // 1秒分ずつ読む
guard let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 一度に)
else { throw 測れない.読めない }
var 済み = 0
while 済み < 全体 {
let 残り = AVAudioFrameCount(min(Int(一度に), 全体 - 済み))
do { try f.read(into: buf, frameCount: 残り) } catch { throw 測れない.読めない }
let 読んだ = Int(buf.frameLength)
if 読んだ == 0 { break }
済み += 読んだ
// ここで100msごとの二乗和に畳んで、buf は次の周で上書きする
}
書き出す側も同じ構造です。1秒分を読み込み、ゲインをかけて書き出します。
while 済み < 全体 {
let 残り = AVAudioFrameCount(min(Int(一度に), 全体 - 済み))
do { try f.read(into: buf, frameCount: 残り) } catch { return やめる() }
let m = Int(buf.frameLength)
if m == 0 { break }
済み += m
guard let d = buf.floatChannelData else { return やめる() }
for c in 0..<Int(fmt.channelCount) {
for i in 0..<m { d[c][i] *= g }
}
do { try out.write(from: buf) } catch { return やめる() }
}
読み終わったところで、例外が投げられる
最初は「読めるだけ読み、0が返ってきたら終了」という書き方をしていましたが、これでは最後に落ちます。
挙動を確かめるため、1,558,400フレームのファイルを48,000フレームずつ読み込んで計測しました。
| 読み込んだ合計 | 起きたこと |
|---|---|
| 1,558,400フレーム | ちょうど全部読み終わる(ファイルの長さと一致) |
| その次の読み込み | 例外が投げられる。返ってきたのは nilError |
残りが0のときは、0フレームを読み込んで返るのではなく、例外を投げます。frameLength が 0 で返ってきたことは、WAVでもm4aでも一度もありませんでした。
Appleの資料に、この挙動についての記述は見当たりません。手元で確認した結果として記載します。
そのため、1回に要求するフレーム数を、残っているフレーム数の範囲に収め、終端を越えないようにしています。
let 残り = AVAudioFrameCount(min(Int(一度に), 全体 - 済み))
引数なしの read(into:) については、資料にこうあります。
When reading sequentially from the framePosition property, the method attempts to fill the buffer to its capacity.
(AVAudioFile read(into:))
最後の1回だけは、バッファの容量より少ないフレーム数に絞りたくなります。Appleは read(into:frameCount:) を「バッファの frameCapacity より少なく読むときに使う」と説明しているので、こちらを使うことになります。
直した後の数字
| 録音の長さ | 前 | 後 |
|---|---|---|
| 10分 | 1.16 GB | 0.01 GB |
| 60分 | 6.92 GB | 0.01 GB |
| 120分(測る+書き出す) | 13.50 GB | 0.02 GB |
120分のファイルを一括で処理しても 12.3秒 で終わります。
計測値は1つも変わっていません(48kHzのファイルの場合)。6本のファイルについて、書き直す前と後の結果を突き合わせ、ラウドネスも真のピークも一致しました。
おわりに
32秒のファイルでは何も起きず、処理も速く、値も合っていました。しかし、手元で動作することと、想定している長さで実用に耐えることは別でした。
入力の長さで破綻する実装は、短いファイルでテストを書いている限り表面化しません。自分のアプリが何分の入力を受け付ける想定なのかを先に決め、その長さで一度計測しておくのが確実です。
この機能を載せる版の画面には「残り録音可能:約 ◯◯ 分(空き容量から算出)」と出す予定でした。長時間録音できると自分で示しておきながら、長いファイルで一度も測っていなかったわけです。
この記事で当たったもの
- AVAudioFile | Apple Developer Documentation
- ITU-R BS.1770-5「Algorithms to measure audio programme loudness and true-peak audio level」(PDF) … 400msブロック・75%重なりの出典
数値はすべて手元の実装と実測に当たって書いています。
チェックワンツー(App Store・無料) … アナライザーは制限なく使えます。