イヤホン配信を支える音のプログラミング〜Accelerate編〜 - Mirrativ Tech Blog

Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

イヤホン配信を支える音のプログラミング〜Accelerate編〜

 こんにちは。shogo4405です。本エントリーは、iOSエンジニア向けにイヤホン配信を支える音のプログラミング入門 - Mirrativ Tech BlogのiOS実装を紹介したいとおもいます。

 本稿では、音声信号処理の実装手法としてAppleが提供するAccelerateフレームワークの利用方法を解説しています。音声の信号処理は、AcceleratevDSPの関連の関数を利用します。

はじめに

 Accelerateを導入した背景は、for文での実装では、Mirrativで扱うライブ配信の処理速度に合わず体験的に良くありませんでした。そこで、Accelerateを導入することで処理速度の向上に寄与し満足できる体験になったため採用にいたりました。*1

サンプルデータ

 せっかくなので自分で音声データを作ってみましょう。sin波によるド・レ・ミ・ファ・ソ・ラ・シ・ドのラ(440hz)の音データを作ってみたいと思います。

func sinWave(hz: Float, sampleCount: Int, sampleRate: Int) -> [Int16] {
  var samples = Array<Int16>(repeating: 0, count: sampleCount)
  for n in 0..<sampleCount {
      samples[n] = Int16(sinf(Float(2.0 * .pi) * hz * Float(n) / Float(sampleRate)) * 16383.0)
  }
  return samples
}

var la = sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100)

 次は、実際に音声を聞いてみましょう。Audacityというソフトウェアを利用しました。上でできたデータをファイルに保存してAudacityで開いてみます。[ファイル]→[ロー(Raw)データの取り込み]から開いてみます。エンコーディングは、32bit-float。サンプリングレートは、44100Hzを選んでください。そうすると次のような波形データが表示されます。[▷]再生ボタンで1秒間のラの音がきけます。これが周波数440hzのsin波の音声です!

前処理

 音声処理で利用するvDSP関数の多くはFloat型の配列を引数として求めています。一方で、iOSで取得できる音声データは、Int16型の配列表現の場合で渡されてくるために変換する必要がでてきます。Int16型配列をFloat型配列に変換する方法を紹介します。

ビット数 表現範囲
16bit Int16 -32,768〜32,767
32bit Float -1.0〜0.0〜1.0
  1. vDSP_vflt16を利用してInt16型の配列データをFloat型の配列データに変更します。
  2. vDSP_vsdivを利用して-32,768〜32,767-1.0〜0.0〜1.0の表現に変更します。
// [Int16]を[Float]へ
func toFloat(intData: [Int16]) -> [Float] {
    var floatData: [Float] = [Float](repeating: 0, count: intData.count)
    var float: Float = Float(Int16.max)
    vDSP_vflt16(intData, 1, &floatData, 1, vDSP_Length(intData.count))
    var result: [Float] = [Float](repeating: 0, count: intData.count)
    vDSP_vsdiv(&floatData, 1, &float, &result, 1, vDSP_Length(intData.count))
    return result
}

前回のおさらい

 前回の記事のおさらいとして以下のまとめを引用しておきます。また、本記事で掲載しているコードはSwift Playgroundsで利用できる形式で最後に公開しています。

  • ある音源とある音源を合成したい
    • PCMデータとPCMデータを加算する
  • 音量を大きくしたい
    • PCMデータに対して1より大きい数を乗算する
  • 音量を小さくしたい
    • PCMデータに対して1より小さい数を乗算する
  • サンプリングレートの変更
    • PCMデータを線形補完する
  • フィルタ処理
    • PCMデータに対してFilterしたいデータを畳み込み演算する

ある音源とある音源を合成したい

 こちらは加算処理になります。vDSP_vaddを利用します*2。ド(261hz)の音声とラ(440hz)の音声を合成してみます。さて。ド(下)の波形をみてみましょう。どうでしょうか。ラ(上)と比較すると山の感覚が小さいきがします。

var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
var d  = toFloat(intData: sinWave(hz: 261, sampleCount: 44100, sampleRate: 44100))
var result = [Float](repeating: 0, count: 44100)

vDSP_vadd(&la, 1, &d, 1, &result, 1, 44100)

 ラとドの合成音声の波形は次のとおりに。なかなかな音がしますね。

音量を大きくしたい

 こちらは乗算処理になります。vDSP_vsmulを利用します。ラのデータを2倍にしてみましょう。

var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
var result = [Float](repeating: 0, count: 44100)
var count: Float = 2.0 // n倍にする

vDSP_vsmul(&la, 1, &count, &result, 1, 44100)

おぉ。波形が上下に大きくなっていますね。再生しても確かに大きくなっています。

サンプリングレートの変更

 サンプリングしたデータを任意のサンプリングレートに基づくように変更することをいいます。リサンプリングともいいます。こちらは、線形補完処理になります。vDSP_genvDSP_vlintを使いました。簡単のために、適当な値の配列を用意してデータをみてみましょう。vDSP_vlintの第二引数の説明には、Single-precision real input vector: integer parts are indices into A and fractional parts are interpolation constants.とあります。つまり、Input値をどのようなOutput値になるか補完していくかのデータになります。

-
input [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
control [0.0, 2.25, 4.5, 6.75, 9.0]
output [1, 3.25, 5.5, 7.75, 10]
var inputs: [Float] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
sampleRateConverter(inputs, vDSP_Length(inputs.count / 2))

// サンプリングレート変換につかう簡単な関数
func sampleRateConverter(_ inputs:[Float], n: vDSP_Length) -> [Float] {
    var base: Float = 0
    var end = Float(inputs.count - 1)
    var control = [Float](repeating: 0, count: Int(n))
    // 第1ステップ
    vDSP_vgen(&base, &end, &control, 1, n)
    // control => [0.0, 2.25, 4.5, 6.75, 9.0]

    var outputs = [Float](repeating: 0, count: Int(n))
    // 第2ステップ
    vDSP_vlint(outputs, control, 1, &result, 1, n, vDSP_Length(inputs.count))
    // outputs => [1, 3.25, 5.5, 7.75, 10] 

    return outputs
}

 さて。実データで計算してみましょう。簡単のために、44.10khzのサンプリングレートの半分にあたる22.05khzに変換したいと思います。

var inputs = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))

// 44.10kHzのサンプリングレートのデータを22.05khzにするのでデータ長を半分にする
sampleRateConverter(inputs, inputs.count / 2)

 おぉ。22.05khzのサンプリングレートで、ラのsin波がきこえました。今度は、ラに、16.0khzのsin波を加えたデータを使ってみましょう。

var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
var ms  = toFloat(intData: sinWave(hz: 16000, sampleCount: 44100, sampleRate: 44100))
var result = [Float](repeating: 0, count: 44100)
vDSP_vadd(&la, 1, &ms, 1, &result, 1, 44100)

 聞いてみます。むむ。ラの音は聞こえます。そして、かすかにピーという音が聞こえるような気がします。16.0khzの音声は、人や環境差によってきこえない場合もあります。さて、このデータのダウンサンプリングを行なってみましょう。

var outputs = sampleRateConverter(values: result, n: vDSP_Length(result.count / 2))
 

 むむ。はっきりピーという音が聞こえますね。このピーの音声は折り返しノイズと言われており、実は入っちゃいけないデータが影響した結果になります。何が問題なのでしょうか。前回の記事のおさらないとして引用しておくと

44.1khzでサンプリングされたデータを半分の1/2にリサンプリングすることを考えます。前処理として、22.05khzのサンプリングレートで表現できない音域を省きます。変換後の22.05khzをローパスするフィルターをかけます。

 22.0khzのサンプリングレートで表現できる範囲は、0.0khz〜11.0khzの音声になります。なので、今回は、16.0khzの音声がよくありませんでした。次セクションで、フィルタ処理で10.0khz以降の音声を削除してみたいと思います。

フィルタ処理

 10.0khz以降を削除するフィルター(ローパスフィルター)処理をしていきましょう。畳み込み演算には、vDSP_convを利用します。次に、ローパスフィルタに使う畳み込み演算のカーネルについてです。こちらは、ハミング窓を使ってこういうカーネル計算式を作成してみました。カーネルとは、畳み込み演算をする上で必要になってくる掛け算の係数になります。

struct FIRFilter {
    static func sinc(_ n: Float) -> Float {
        return n == 0 ? 1.0 : sin(n) / n
    }

    static func low(_ fe: Float, count: Int) -> [Float] {
        let offset = Int(count / 2)
        var result: [Float] = .init(repeating: 0, count: count)
        for m in -count / 2...count / 2 {
            result[offset + m] = 2.0 * fe * sinc(2.0 * .pi * fe * Float(m))
        }
        var window: [Float] = .init(repeating: 0, count: count)
        vDSP_hamm_window(&window, UInt(count), 0)
        vDSP_vmul(result, 1, window, 1, &result, 1, UInt(count))
        return result
    }
}

// ローパスフィルターをかける
var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
var ms  = toFloat(intData: sinWave(hz: 16000, sampleCount: 44100, sampleRate: 44100))
var samples = [Float](repeating: 0, count: 44100)
vDSP_vadd(&la, 1, &ms, 1, &samples, 1, 44100)

// カットしたい周波数今回は、10.0khzなので10000.0 / オリジナルデータのサンプリングレートの順番で与える
let filter: [Float] = FIRFilter.low(10000.0 / 44100.0, count: 5).reversed()
var result = [Float](repeating: 0, count: samples.count)
vDSP_conv(samples, 1, filter, 1, &result, 1, UInt(result.count), UInt(filter.count))

では、結果をみてみましょう。おぉ。純粋なラのsin波に近づきましたね!

むすびに

 イヤホン配信機能は、このページで紹介したAccelerate関数を使いながら実装しています。また、ライブ配信は、リアルタイム性が求められるため処理コストがかからないように実際のコード上では多くの工夫をしています。

We are hiring!

 ミラティブでは一緒にアプリを作ってくれる iOS/Androidのエンジニアを募集しています!
少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。 www.mirrativ.co.jp

巻末付録

 Swift Playgroundで、遊んでみてくださいね。

import UIKit
import Accelerate
import Foundation
func sinWave(hz: Float, sampleCount: Int, sampleRate: Int) -> [Int16] {
  var samples = Array<Int16>(repeating: 0, count: sampleCount)
  for n in 0..<sampleCount {
      samples[n] = Int16(sinf(Float(2.0 * .pi) * hz * Float(n) / Float(sampleRate)) * 16383.0)
  }
  return samples
}
func filePutContents(data: Data, fileName: String) {
    guard let dirURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
        fatalError("フォルダURL取得エラー")
    }
    let fileURL = dirURL.appendingPathComponent(fileName)
    print(fileURL)
    do {
        try data.write(to: fileURL)
    } catch {
        print("Error: \(error)")
    }
}
func toFloat(intData: [Int16]) -> [Float] {
    var floatData: [Float] = [Float](repeating: 0, count: intData.count)
    var float: Float = Float(Int16.max)
    vDSP_vflt16(intData, 1, &floatData, 1, vDSP_Length(intData.count))
    var result: [Float] = [Float](repeating: 0, count: intData.count)
    vDSP_vsdiv(&floatData, 1, &float, &result, 1, vDSP_Length(intData.count))
    return result
}
// 音の合成
do {
  var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
  var d  = toFloat(intData: sinWave(hz: 261, sampleCount: 44100, sampleRate: 44100))
  var result = [Float](repeating: 0, count: 44100)
  vDSP_vadd(&la, 1, &d, 1, &result, 1, 44100)
    
    filePutContents(data: Data(bytes: &result, count: result.count * 4), fileName: "data-add.raw")
}
// 音量の調整
do {
  var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
  var result = [Float](repeating: 0, count: 44100)
  var count: Float = 2.0 // n倍にする
  vDSP_vsmul(&la, 1, &count, &result, 1, 44100)
    
    filePutContents(data: Data(bytes: &result, count: result.count * 4), fileName: "data-volume.raw")
}
// サンプリングレートの変換
do {
    func sampleRateConverter(values: [Float], n: vDSP_Length) -> [Float] {
        var base: Float = 0
        var end = Float(values.count - 1)
        var control = [Float](repeating: 0, count: Int(n))
        vDSP_vgen(&base,
                  &end,
                  &control,
                  1,
                  n)
        
        var result = [Float](repeating: 0, count: Int(n))
        vDSP_vlint(values,
                   control,
                   1,
                   &result,
                   1,
                   n,
                   vDSP_Length(values.count))
        return result
    }
    
    var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
    var ms  = toFloat(intData: sinWave(hz: 16000, sampleCount: 44100, sampleRate: 44100))
    var result = [Float](repeating: 0, count: 44100)
    vDSP_vadd(&la, 1, &ms, 1, &result, 1, 44100)
    
    filePutContents(data: Data(bytes: &result, count: result.count * 4), fileName: "data-sample-rate-1.raw")
    
    var outputs = sampleRateConverter(values: result, n: vDSP_Length(result.count / 2))
    filePutContents(data: Data(bytes: &outputs, count: outputs.count * 4), fileName: "data-sample-rate-2.raw")
}
do {
    struct FIRFilter {
        static func sinc(_ n: Float) -> Float {
            return n == 0 ? 1.0 : sin(n) / n
        }
        static func low(_ fe: Float, count: Int) -> [Float] {
            let offset = Int(count / 2)
            var result: [Float] = .init(repeating: 0, count: count)
            for m in -count / 2...count / 2 {
                result[offset + m] = 2.0 * fe * sinc(2.0 * .pi * fe * Float(m))
            }
            var window: [Float] = .init(repeating: 0, count: count)
            vDSP_hamm_window(&window, UInt(count), 0)
            vDSP_vmul(result, 1, window, 1, &result, 1, UInt(count))
            return result
        }
    }
    
    // ローパスフィルターをかける
    var la = toFloat(intData: sinWave(hz: 440, sampleCount: 44100, sampleRate: 44100))
    var ms  = toFloat(intData: sinWave(hz: 16000, sampleCount: 44100, sampleRate: 44100))
    var samples = [Float](repeating: 0, count: 44100)
    vDSP_vadd(&la, 1, &ms, 1, &samples, 1, 44100)
    let filter: [Float] = FIRFilter.low(20000.0 / 2.0 / 44100.0, count: 5).reversed()
    var result = [Float](repeating: 0, count: samples.count)
    vDSP_conv(samples, 1, filter, 1, &result, 1, UInt(result.count), UInt(filter.count))
    filePutContents(data: Data(bytes: &result, count: result.count * 4), fileName: "data-conv.raw")
}

*1:ベンチマーク結果も何かのときに公開できればと思っています。

*2:処理内容によっては、vDSP_vtmergのほうが使いやすいかもしれません。