PolyHexane サウンドルーチンもSwiftで

音を鳴らすアプリは色々作ってきましたが、Swiftでは生のポインタをいじったりするのに色々制限があり、Obj-CあるいはほぼCで書くのが良いと思っていました(今でも基本的にはそう思っています)。

が、SwiftでもCoreAudio周りのルーチンが書けるという記事を読んだので試してみました。結論から言うとデバッグモードでは配列のチェックなどはいるようでかなり遅いですが、まあ動きます。見通しの良いコードが書けるのは良い点かもしれません。

PolyHexaneのサウンドルーチンでは正弦波を鳴らす機能を作りました。単に鳴らすだけではなく減衰するのと、複数の周波数の音を重ねて鳴らすようにしてあります。数字を音にする、ということで与えられた数nを因数分解して、その因数分の1の周波数の音を出すことで、元の周波数とある周期で共鳴する音をだすようになっています。

一つの音はしばらくすると減衰してバッファから取り除かれます。

音を鳴らすルーチンはどのスレッドからでも呼べるようになっています。音を鳴らすコマンドはnotes_to_addという配列にストアされます。AudioUnitを鳴らす方のコールバックで、notes_to_addをチェックして、mutexで同時アクセスになっていないことを確認した上で現在なっている音の集合であるnotesに追加します。同時アクセスになった場合は、他のルーチンがアクセスを終わるのを待ってもいいのですが、コールバック関数でタイム・アウトすると音が途切れてしまいますので、この周期では新たな音を追加しないようにしています。

あらたに音を鳴らすコマンドであるplay関数からnotesに直接アクセスすると、コールバック関数との間で衝突が生じやすくなるのと、衝突したときに音のデータが全部読めなくなって不都合が生じるので、notes_to_addに衝突しうる処理を追い出して音の乱れが起こりにくいようにしてあります。

import Foundation
import AudioUnit

let sampleRate = 44100.0

class SNPlayer {

  struct Note {
    let start_time:Double
    let tones:[Double]
    let decay_rate:Double
    init(time:Double, note:[Double], decay:Double){
      start_time = time
      tones = note
      decay_rate = decay
    }
  }

  let delta:Double = 1.0 / sampleRate
  var audioUnit: AudioComponentInstance?
  var mlock:pthread_mutex_t = pthread_mutex_t()
  var notes:[Note] = []
  var elapsed_time:Double = 0
  var notes_to_add:[Note] = [] // 新たに鳴らす音は次の割り込み周期で処理。 mutexで保護

  let callback: AURenderCallback = {
    (inRefCon: UnsafeMutableRawPointer,
    ioActionFlags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
    inTimeStamp: UnsafePointer<AudioTimeStamp>, inBusNumber: UInt32, inNumberFrames: UInt32,
    ioData: UnsafeMutablePointer<AudioBufferList>?) in

    let player:SNPlayer = Unmanaged<SNPlayer>.fromOpaque(inRefCon).takeUnretainedValue()
    return player.render(ioActionFlags: ioActionFlags,
      inTimeStamp: inTimeStamp,
      inBusNumber: inBusNumber,
      inNumberFrames: inNumberFrames,
      ioData: ioData)
  }

  init() {
    var acd = AudioComponentDescription();
    acd.componentType = kAudioUnitType_Output;
    acd.componentSubType = kAudioUnitSubType_RemoteIO;
    acd.componentManufacturer = kAudioUnitManufacturer_Apple;
    acd.componentFlags = 0;
    acd.componentFlagsMask = 0;
    let ac = AudioComponentFindNext(nil, &acd);
    AudioComponentInstanceNew(ac!, &audioUnit);
    if audioUnit != nil{
      var asbd = AudioStreamBasicDescription(mSampleRate: sampleRate, mFormatID: kAudioFormatLinearPCM,
        mFormatFlags: kAudioFormatFlagsNativeFloatPacked|kAudioFormatFlagIsNonInterleaved,
        mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, mChannelsPerFrame: 2, mBitsPerChannel: 32, mReserved: 0)
      AudioUnitSetProperty(audioUnit!, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &asbd, UInt32(MemoryLayout.size(ofValue: asbd)))

      let ref: UnsafeMutableRawPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
      var callbackstruct:AURenderCallbackStruct = AURenderCallbackStruct(inputProc: callback, inputProcRefCon: ref)
      AudioUnitSetProperty(audioUnit!, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, &callbackstruct, UInt32(MemoryLayout.size(ofValue: callbackstruct)))
      AudioUnitInitialize(audioUnit!)
      AudioOutputUnitStart(audioUnit!)
      pthread_mutex_init(&mlock, nil)
    }else{
      print("Failed to init AudioUnit")
    }
  }

  func render(ioActionFlags: UnsafeMutablePointer<AudioUnitRenderActionFlags>, inTimeStamp: UnsafePointer<AudioTimeStamp>, inBusNumber: UInt32, inNumberFrames: UInt32, ioData: UnsafeMutablePointer<AudioBufferList>?) -> OSStatus {
    guard let abl = UnsafeMutableAudioBufferListPointer(ioData) else {
      return noErr
    }
    let bufL:UnsafeMutablePointer<Float> = (abl[0].mData?.bindMemory(to: Float.self, capacity: Int(inNumberFrames)))!
    let bufR:UnsafeMutablePointer<Float> = (abl[1].mData?.bindMemory(to: Float.self, capacity: Int(inNumberFrames)))!

    if pthread_mutex_trylock(&mlock) == 0{
      notes.append(contentsOf: notes_to_add)
      notes_to_add = []
      pthread_mutex_unlock(&mlock)
    }

    for i in 0..<inNumberFrames{
      var out:Double = 0
      for n in notes{
        let dt = elapsed_time - n.start_time
        var decay = 0.2 * exp(dt * n.decay_rate) / Double(4 + n.tones.count)
        if dt < 0.01 {
          decay *= (dt/0.01)
        }
        for f in n.tones{
          out += sin(2 * .pi * f * dt) * decay
        }
      }
      if out > 1.0 {out = 1.0}
      if out < -1.0 {out = -1.0}
      bufL[Int(i)] = Float(out)
      bufR[Int(i)] = Float(out)
      elapsed_time += delta
    }

    notes = notes.filter {elapsed_time - $0.start_time < 3 / (-$0.decay_rate)}
    return noErr
  }

  func play(_ n:Int){
    func fact(_ N:Int)->Int{
      for i in 2..<N{
        if N % i == 0 { return i }
      }
      return N
    }
    var x = n
    let basetone = 440.0
    var k = 0.0
    var tones = [basetone]
    while x > 1{
      let f = fact(x)
      tones.append(basetone * (k + 4.0) / Double(f))
      x /= f
      k += 1.0
    }
    let note = Note(time:elapsed_time,note:tones, decay:-3)
    pthread_mutex_lock(&mlock)
    notes_to_add.append(note)
    pthread_mutex_unlock(&mlock)
  }
}

 

PolyHexane 六角形迷路生成のアルゴリズム

しばらく前にやねうらおさんというコンピュータ将棋ソフト「やねうら王」などを作られている方のブログで迷路生成アルゴリズムについて読んだ。

古くて新しい自動迷路生成アルゴリズム

そっからのリンクでクラスタリングによる迷路作成アルゴリズム

その後しばらくそのことも忘れていたが、ふとした時に六角形の迷路で同じアルゴリズムを使えるか考えてみた。

仮に4x4の迷路を作るとして、図のように番号を振ることとする

ルーチンcreateで横X 縦Yの迷路を作成する。

部屋の間の壁を考える(struct Wall)。部屋Aと部屋Bの間の壁で、向きは{縦・右が上・左が上}の3種類がある。すべての壁を列挙する。配列Poolに入れておく
→本当は一番ここが面倒なところ。縦棒の壁はすごくわかりやすいが、斜めの壁はYが奇数・偶数で変わってくるなどあり、多少の場合分けが必要。ソースをご参照ください。

初めはすべての部屋が別のクラスタに属するので、部屋一つずつに対応する配列を作って別々のクラスタ番号を割り振る。

最終的な迷路に残る壁を入れる配列としてwalls
各部屋から隣接して移動できる部屋の配列としてlink という配列の配列を用意する

配列Poolが空になるまでループ:
ランダムにPoolから壁を一つ取り出す この壁は部屋Aと部屋Bの間にあるとする
部屋A,Bのクラスタ番号を比較
同じクラスタ:この壁は残す 配列wallsに追加
違うクラスタ:この壁は取り払う 両側の部屋のクラスタ番号を同じにする
部屋A,Bが繋がったのでlink[A]にBを追加、link[B]にAを追加

これで迷路は完成しているが、スタート地点からの距離を調べておくと便利なのでここで計算しておく distanceという各部屋に対応した配列を作り-1で初期化
部屋pについて link[p]で隣接している部屋のうち、distanceが-1のものは自分の距離+1に設定した上で、その部屋について再帰的に適用する。
部屋0からスタート

というアルゴリズムをSwiftで書いたのが以下のソースです。PolyHexaneの内部では対戦モード向けに反転した迷路を作らなければいけないのでもうちょっと余計な処理が増えています。


class Maze{

  enum WallDirection{
    case None
    case StraightUp
    case RightUp
    case LeftUp
  }

  struct Wall {
    var A:Int
    var B:Int
    var Direction:WallDirection
    init(a:Int, b:Int, dir:WallDirection){
      A = a
      B = b
      Direction = dir
    }
  }

  private var Pool = [Wall]()
  private var clusterNumber = [Int]()

  var walls = [Wall]()
  var link = [[Int]]()
  var distance = [Int]()

  func create(X:Int, Y :Int){
    walls = []
    Pool = []
    clusterNumber = []
    for i in 0...X*Y-1{
      clusterNumber.append(i)
    }
    link = Array(repeating: [], count: X*Y)

    for y in 0...Y-1{
      for x in 0...X-2 {
        Pool.append(Wall(a: y*X+x, b: y*X+x+1, dir: .StraightUp))
      }
      if y > 0{
        if y % 2 == 1{
          for x in 0...X-1 {
            Pool.append(Wall(a: y*X-X + x, b: y*X + x, dir: .RightUp))
          }
          for x in 0...X-2 {
            Pool.append(Wall(a: y*X-X + x+1, b: y*X + x, dir: .LeftUp))
          }
        }else{
          for x in 0...X-1 {
            Pool.append(Wall(a: y*X-X + x, b: y*X + x, dir: .LeftUp))
          }
          for x in 1...X-1 {
            Pool.append(Wall(a: y*X-X + x-1, b: y*X + x, dir: .RightUp))
          }
        }
      }
    }

    while !Pool.isEmpty {
      var n = Int(drand48() * Double(Pool.count))
      let a = clusterNumber[Pool[n].A]
      let b = clusterNumber[Pool[n].B]
      if a == b {
        // this wall stands within a cluster so add it to remaining 'walls'
        walls.append(Pool[n])
      }else{
        // cluster B is merged to A
        link[Pool[n].A].append(Pool[n].B)
        link[Pool[n].B].append(Pool[n].A)
        for i in 0...X*Y-1{
          if clusterNumber[i] == b {
            clusterNumber[i] = a
          }
        }
      }
      Pool.remove(at: n)
    }

    distance = Array(repeating: -1, count: X*Y)
    func dig(_ p:Int){
      for i in link[p]{
        if distance[i] == -1{
          distance[i] = distance[p]+1
          dig(i)
        }
      }
    }
    distance[0] = 0
    dig(0)
  }
}

HenPitsu 4.0 – Swift 3.0で書き直し

今年の正月は実家に帰省せず自宅にいました。大晦日にランニングしたところ数日筋肉痛がひどく自宅からなかなか出られなかったこともありSwiftが3.0になってどのような変更があったのかをみていました。
[iOS 10] UIGraphicsImageRenderer について というのが見通しの良いコードが描けそうなので、HenPitsuをSwiftで書き直してみることにしました。

一番の変更点は、背景の写真と文字のレイヤーを分けて、表示・エクスポート時に合成するようにすることです。これにより、後から自分の書いた画だけあるいは背景だけ消したりすることが出来るようになりました。さらにはこれまでおざなりにしていた書き味の細かいチューニングもやってみました。

一部でバグを見つけたり、今でも何故動くのかわからない部分もありながらなんとか完成しました。あと、AppleがiADから撤退したせいでアプリからの広告収入がなくなっていたのですが、GoogleのAdMobをCocoapods経由で組み込んでみました。

ということでver4.0ができたのですが、UIGraphicsImageRendererを使うにはそのためだけに対応OSがver10以上となってしまいます。ver9.0以上で動くようにするために自力で擬似的なものを作ってみました。本当は内部バッファを自分で確保して内部ではcgImageを保持して必要なときにUIImageを生成するようにしてみたのですが、座標軸が上下反転する問題に巻き込まれてかえってややこしくなってしまったので、馬鹿みたいに簡単なものになりました。指定通りのサイズのビットマップを作るのか、Retina対応でスケール対応したビットマップを作るのかで関数を変えています。

なお起動に時間がかかるようになったのはAdMobのせいだと思います。

< SNGraphicsImageRenderer.swift >
import UIKit
class SNGraphicsImageRenderer{
    var sz:CGSize
    init(size: CGSize){
        sz = size
    }
    func image(actions: (CGContext) -> Void) -> UIImage?{
        UIGraphicsBeginImageContext(sz)
        if let con = UIGraphicsGetCurrentContext(){
            actions(con)
            let img = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            return img
        }
        return nil
    }
    
    func imageScaled(actions: (CGContext) -> Void) -> UIImage?{
        UIGraphicsBeginImageContextWithOptions(sz, false, 0.0)
        if let con = UIGraphicsGetCurrentContext(){
            actions(con)
            let img = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            return img
        }
        return nil
    }
}

MDX Player for iOS ver 2を更に高音質化するHiDef版のガイド

MDX Player ver2で再生周波数を変更できるようになりました(22,44,48,62.5KHz)
62.5KHzでは音質が良いのですが、他の周波数では内部で62.5KHzでエミュレートした上でダウンサンプリングをしていて、そのせいで音質が悪化します。

変更点

エミュレートエンジンであるGAMDXを改造して、
1) 指定周波数でのエミュレートを行いそのまま出力する(GAMDXの内部処理を変更)
2)ダウンサンプリングが必要なときは、高音質のリサンプラーを通すようにする
3) ADPCMは15.6KHzからアップサンプリングしているので、そこもリサンプラーを通す
リサンプラーはBSDライセンスのspeexライブラリを使用しました。2),3)はコンパイル時に使用・不使用を設定できるようにしました(後述)。iOS版では2)はメリットが殆ど無いのでデフォルトではオフにしてあります

4)また、究極の高音質化として、62.5KHz のMDX出力をダウンサンプルすること無くアップサンプルして出力できるよう、192KHzまでのHiDef DAC(外付け)に対応しました(実際に鳴っているところをまだ確認できていませんが

5)内部処理と、出力の周波数が違うので、周波数ボタンを2段にして上が内部処理、下が外部出力周波数とします

6)MDXを聞きながらポケモンGOをしていて気づいたのですが、一部のMDXファイルではボリュームが小さめのものがあります。ボリュームスイッチで音量を上げると、ポケモンGOの効果音が非常に大きくなってしまいます。せっかく内部のボリュームバーが付いたので、ボリュームを0〜1ではなく2まであげられるようにしてみました。

7)ボリュームを内部処理で上げると、16bitPCMの範囲からオーバーフローすることがあります(実はこれまでも一部の曲ではオーバーフローしていることはあった)。オーバーフローした時は自動的にボリュームを下げる機能をつけました(Automatic Gain Control)。ただ、次の曲に移ったときもそのボリュームが維持されるのが難点ですが。

8)周波数を変更したときにハングすることが一度だけ確認できました(何処かでMutexが開放されていないため?)。曲選択でエラーになったときにMutexが開放されていませんでしたので直しました。またしばらくMutex確保できないときにはタイム・アウトするようにしてみました。

使用法

https://github.com/sinn246/MDXPlayer/tree/HiDef においてあります。

フォルダ GAMDX > jni > speex というのが新たに導入したリサンプラーのファイル群です。
そのなかで speex_MDX.h というヘッダファイルにコンパイル時設定をまとめました。

#define USE_SPEEX
これを設定するとspeexルーチンが導入されます。設定しなければもとのままですが、リサンプラーはどうなるかというと、AppleがCoreAudioの中で勝手にリサンプルしてくれます

#define USE_SPEEX_FOR_ADPCM_UPSAMPLING
これを設定すると、ADPCMのアップサンプリングにspeexを使います。アップサンプリングにはわずかにディレイが生じるので、微妙にADPCMだけ音が遅れることにつながりますが、私にはほとんどわかりません・・・

// #define USE_SPEEX_FOR_DOWNSAMPLING
62.5KHzからダウンサンプルする必要があるとき、CoreAudioに任せずspeexライブラリを使用します。iPhoneで使う分には特に音質が上がるわけではなく(CoreAudioも音質は悪くなくさらに最適化されていると考えられるので)、研究的なもの(Androidなどに移植するときに使えるかも)として一応ソース上は残してあります(が、オフにしてあります

GAMDX改造:ADPCM変更の効果は?

このところGAMDX改造の話題が続きましたがそろそろ終わりになります。

もとのGAMDXではADPCMの15.6KHzの音源を内部周波数の62.5KHzにアップサンプリング、その際にローパスフィルターを通していますが何故かpcm8.cppおよびX68pcm8.cppという2つのルーチンで2重にローパスフィルターを通っています。ローパスフィルターのパラメータは固定なので、アップサンプリングする周波数が変わってしまうと問題が生じそうです。また、2回LPFを通るので音の歪みなどが必要以上に乗ってしまう可能性はあるかもしれません。追記:LPFの効果を強めたい場合、2つのLPFを直列につなぐのは「あり」のようですので、音質の劣化はあまり心配しなくていいかもしれません。

この処理を少し変えて、GAMDX内部での周波数(可変にしてみました)に合わせてアップサンプリング、そのあとADPCMが15.6KHzなのでその半分の7KHz以上の成分はノイズと考えられるのでアップサンプリングした周波数に合わせてローパスフィルタを通す、という風にしてあります。

フィルタが変わったことで音質の比較のため内蔵の「X68030のテーマ」のボイスパートを使ってみました。ADPCMの比較をするために、FM音源のところはプログラム上でカットしてあります。

1)もとのGAMDX(MDX player 2.0, 62.5KHz出力を使用。したがってダウンサンプラーはAppleのものが使われます)

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-48-27

出だし約10秒間を詳しく周波数分析したもの

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-48-42

2)改造版、ダウンサンプラーはAppleの物を使用

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-41-01

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-41-15

3)改造版、ダウンサンプラーはSpeex(Quality=2)を使用

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-43-27

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-43-57

4)改造版、Speexリサンプラー(Quality=4)

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-56-30

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-13-17-56-57

 

SpeexリサンプラーはQuality=2と4でほぼ同じ結果のようです。あとでソースを読んで見る必要あり。AppleよりもSpeexのほうがローパスな傾向があるのと、私が改造したADPCMルーチンは8KHz以上の本来はカットされるべき部分が結構残っているようなのでLPFのパラメータはいじる余地がありそうです。

GAMDXのリサンプラーをいれかえてみる

前回に引き続きGAMDXの改造を継続。

まずADPCMをいじっているときに、内部で使用しているSample型が16bitに設定されていたのでPCMデータがオーバーフローする状況に遭遇。ローパスフィルターを整理するとともに極力データのロスがないように、原則データロスにつながる操作をしないようにしてみた結果なのですが。ということでSample 型は32bitにしてあります。

そしてもともとのダウンサンプラーの性能が良くないと指摘してAppleの(iOS組み込みの)ダウンサンプラーを使っていたのですが、他のOSに移植することもあるかもしれませんし、ダウンサンプラーを差し替えてみようと考えました。

ダウンサンプラーとアップサンプラーをあわせてリサンプラーというのですがそのものを総合的に解説したHPがあります:Digital Audio Resampling Home Page

色々解説が書いてありますがこれを勉強して一から書くのは相当大変というか無理です。ということでこのHPのFree Resampling Softwaresというところでフリーの実装を物色してみます。SoXというのが有名らしいがかなりボリュームが多いしGPLだ・・と思っていたら一番下の方でSpeexライブラリがBSDライセンス(商用利用も可能)となっています。Speex自体はVoIPで音声を圧縮して通話することが主目的のライブラリですが、リサンプラーも半ば独立したものがあるみたい(Speex自体は開発終了していてOpusという新しいプロジェクトに移っているようですが、リサンプラーはあまり変わらないようです)。通話目的のプロジェクトなので、速度も重視されているようです。

ということで秋の夜長、Speexのresampler.cを移植してみました。1.2rc3をダウンロードして、そのものズバリresampler.cというのを入れてみて、コンパイルエラーが起こらないまでにヘッダーを持ち込みます。.cファイルは一つだけというのが好感が持てます。リサンプルの精度は0-10で選べます。ARMのNEON命令セットに対応しているようですが、今はうまく動かないようです。

GAMDXの内部にはMXDRVG_MakeResampler,  MXDRVG_ClearResamplerという2つの関数を追加しただけで、他はほぼ透過的にリサンプルする・しないが選べます。もとのGAMDXではMXDRVG_Startという関数で出力周波数を指定しますが、ここでは内部処理の周波数を指定して、出力周波数を決めるのはResamplerでという風にしたのでフレキシブルになっていると思います。

手元にUSB-DAC/ADCがないのでリサンプラーごとの性能比較が今はできないので後日。

先述の通りSpeexのリサンプラーは精度が0-10で選べます。デフォルトは4になっていて,0では線形補間のレベルと書いてあります(が、ソースの中をちゃんと読んでいないので後の課題にしておきましょう)。5以上のレベルはCPUにかかる負荷が重くなりそうなので、レベル4,2,0について調べてみました。62.5KHzの内部周波数で、48KHzにダウンサンプリングして出力したものです。

レベル4

level4

レベル2

level2

レベル0

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-11-16-26-00

もとのGAMDXリサンプラーと直接比べられるように同じ曲を調べてみました。ノイズは明らかに減っていて、Appleのリサンプラーとあまり違いないレベルでしょうか。レベルごとの違いについて、実際に耳で聞いたところでもあまり変わらないようですが、CPU使用率は結構違います。iPhone5sではレベル4でCPUの使用率30%程度、レベル0で20%程度となります。レベル2くらいでいいのかもしれません。

なお、前回の48KHzの出力で1)GAMDX内部は62500Hz、Appleのリサンプラーで48KHzに変換 3)GAMDX内部から48KHzで処理、を比較してみる話ですが、手元にあったVeyrlen氏のSonic Boom Stage 1 (高校生の時に松山銀天街のセガでやったのを思い出す・・・)はあきらかに1)のほうが良いみたいです。ほかの曲では大きな違いがない様に聞こえるのですが。

ということで現時点での結論としてiOS についてはベストの音質は62.5KHzにApple resamplerをかましたもの、となりver2.0からはあまり大きな改善は見込めなさそうです。他のOSについてはSpeex resamplerがうまくいけば音質向上になると思います。

GAMDX改造の効果は?(続き)

昨日に引き続き、今度はFM音源の出力を本来の62500Hzではなく48000Hzにしたときの比較をしてみます。

1)MDXPlayer for iOS ver2.0 62.5KHz: GAMDX  FM音源62500Hz  – iOS内蔵のダウンサンプラー経由で48KHz出力

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-06-17-07-11

2)MDXPlayer for iOS ver2.0 48KHz: GAMDX  FM音源62500Hz  – GAMDX内蔵のダウンサンプラー経由で48KHz出力

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-06-17-07-57

3)改造版:GAMDX 改造して FM音源48000Hz  -ダウンサンプラー通さず48KHz出力

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-06-17-06-58

ということで、周波数帯の灰色の部分は無音に近いということになりますから、2)は全体的にホワイトノイズが乗ったような感じになっているといえます。ダウンサンプラーを通さないことで余計なノイズは乗らず1)と3)ほぼ互角のように見えます。ただ、FM音源の出力を間引いて出力しているかもしれないので、細かい波形の歪みが出ていないかが気になります。192KHzでキャプチャして、こまかいところまでみてみました。なお、本日使用した曲はVeyrlen氏によるS.D.I.のAn Imminent Warです(FM音源の名曲ですので対決にちょうどよいかと)

前述の1)

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-06-17-24-53

2)

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-06-17-26-41

3)

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-06-17-24-39

うーん、少なくとも波形の極端な歪みなどはなさそうですが、比較が困難です。すくなくとも、FMgenから48000Hzで出力しても、大きな破綻はないと考えていいでしょうか?

あとは62500Hzの原音と48000Hzの音の波形を比べてみるのが良いのでしょうが、そうなるとまずMacのかんたんなプログラム(コマンドラインから起動?)で62500HzのデータをWAVなどの形でダンプするプログラムを作って、1)のダウンサンプラーを通したほうが良いか、3)のFM音源エミュレータから48000Hzで出したのでかまわないのか、比較する他ないかと思います。あとはご自身の耳でも聴き比べてみてください。

MDX音質向上研究:GAMDX改造

前回の続きでMDX Player for iOSの音質向上を図ってみます。

まず前提として、X68内部ではFM音源は62500Hz, ADPCMは15625Hzで出力されています。

MDX Player for iOSではGAMDXエンジンを使用していて、FM音源とADPCM音源をミックスしています。GAMDXエンジンの内部では

  1. FM音源は62500Hzで生成、
  2. ADPCM音源を62500Hzになるよう4倍化した上でフィルタをかけて高音域に乗ったノイズを除去、
  3. FMとADPCMをミックスした上で目的の周波数(44100Hzとか)にダウンサンプル、

となっています。前回指摘したのは、このダウンサンプルのところでノイズが乗ってしまっているのでは、ということでした(周波数変換自体は非常に奥深い問題で、速度と品質のトレードオフなどもあり一筋縄では行きません。各種の周波数変換ルーチンの比較をするサイトもあります)。

前回、MDX Player for iOS v2.0では62.5KHzの出力にした時は、内部のダウンサンプルルーチンを使わずにAppleのダウンサンプルルーチンを使うようになっていて、その結果音質が向上していることを確認しました。その結果をふまえて、GAMDX 内部をいじってみて音質向上を図ってみます。v2.0にはせっかく22/44/48/62KHzと切り替える機能もついているので、内部的な周波数を変更できるようにしてみました。

まずGAMDXでは内部的に62500Hzに統一していますが、これを可変にしてみます。実はFM音源のエミュレートをしているFmgenは周波数が変わっても出力は可能(周波数が下がると音質も落ちますが)です。また、ADPCM音源のエミュレートはX68pcm8.cpp, pcm8.cppという2段階で行われていますが、何故かローパスフィルタを2回通っています。またローパスフィルタの係数は固定されているので、内部の周波数が変わったときにうまくいかなくなると思われます。ということで、改造したエンジンでは

  1.  FM音源は指定した周波数 xxHzで生成
  2. ADPCMをxxHzにアップサンプリングする。途中までは余計なフィルタは通さず、FM音源とミックスする直前にローパスフィルタ(指定の周波数xxHzによりパラメータを変更するタイプ)を通す
  3. 直接xxHzのデータをiOSに渡して再生してもらう;iOSの機能で必要に応じて44KHzや48KHzに周波数変換してくれるし、もし44KHzで生成すればダウンサンプリング不要となるのでそこでのロスがない

というかたちにしてみました。62KHzでは素のver2.0とほぼ同様です。22, 44, 48KHzの存在意義ですが、再生ルーチンの負担が軽くなるので62KHzに比べて22KHzではCPUにかかる負荷が半分くらいになるようです。なお、MMDSP風の画面更新は周波数に比例した頻度で行うので、22KHzだと画面はスローがかかったようにみえるかもしれません(Frame per secondがひくい)。

再生音質の比較をしたいのですが、わたしはiPhoneを1つしか持っていないので改造版ともとのやつを比較するのにXCodeでプロジェクトを切り替えてインストールして・・・を反復しないといけません。本来なら、GAMDXエンジンで62500Hzを44100Hzにダウンサンプルした場合と、はじめから44100Hzで生成してダウンサンプルしない場合とを比較した方がいいのでしょうが。

もう少し時間のあるときにいろいろ調べてみますが、今はとりあえず新規導入したADPCMのローパスフィルタの性能を評価してみました。ADPCMのみのパート(アサルト2ステージ出だしのドラムだけのところ)を周波数分析してみます:

1)ローパスフィルタを通さずにそのままま出力:15625Hzを中心にエイリアシング(折り返し雑音)が見られます。

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-05-16-00-14

2)もとのGAMDXのエンジン:ローパスフィルタが効いています。

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-05-16-50-54

3)今回導入した可変ローパスフィルタ:7KHzより上の周波数は本来は雑音ということになるので2)のフィルタよりも少し性能は悪いかもしれませんが、2)のフィルタは前述の通り2回のフィルタ操作を通っているのでもう少し素直な音がしてくれることが期待できるかも・・・??

%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2016-10-05-16-00-25

 

ということで、ここまでの変更はhttps://github.com/sinn246/MDXPlayer/tree/HiDefにあげておきます。

GAMDXの内部構造としてはローパスフィルタを別ルーチンにしたのでPCM周りは少しスッキリしたと思います。あとは優秀な周波数変換ルーチンがあればAndroidなどでも音質向上できるはずです(と言うか、私が加えた変更よりも周波数変換ルーチンの差し替えだけでも十分かもしれません)。

続きがあります

MDX Player for iOS音質向上研究

MDX Player ver 2.0 から再生周波数の切り替え機能(22,44,48,62Khz)がつきました。

22, 44, 48KHzはわかりますが(CD,DATなどの周波数で一般的によく使われる)、62KHzというのは何かというと、X68000のOPMチップに与えられる4MHzの周波数を64分周したADPCMが15.6KHzなので内部処理を楽にするために?44,48,62KHzのときは15.6×4=62.5KHzがOPMチップの出力周波数となります。ということでGAMDX(MDXドライバ)内部では62.5KHzで処理しており、そのデータをそのままiPhoneのCoreAudioに渡すモードということになります。実際に62KHzで出力されるのではなく、CoreAudio内部で44KHzに変換されて出力されているようです(iPhoneのイヤホン出力をUSB-DAC/ADCに繋いで解析しました)。44, 48KhzのときはGAMDX内部で62KHzから周波数変換されて返されます。なお、22KHzのときだけなぜかGAMDX内部でも22Khzで処理されています(謎)

(追記:上記消去した部分はGAMDXドライバというかMXDRVgを作られたGORRYさんから直接Twitterで指摘されました)

iPhoneは48KHzの出力にも対応しているはずですが、48KHzモードでも44KHzで出力されているようです?と思ってMDX Player for iOSのソースを見てみたらAVAudioSessionのsetPreferredSampleRateが設定されていませんでした。直しておきましょう。

さて本題です。

MDXファイルを再生していると高音域がやけに耳に響くものがあります。もともとFM音源の音はそんなものだという考え方もありますが、とくにADPCMの音が響くと思います。そこで再生周波数の変化でデータがどのように違うか見てみましょう。iPhoneのイヤホン出力をAudacityというソフトで取り込んで(96KHz)、スペクトル表示にしてみました。

62KHz(実際の出力時には44KHzに変換されています)

62KHz

44KHz

44KHz

22KHz

22KHz

特に44KHzは本来の音の周波数以外のところにノイズが乗って全体的に水色がかった感じになっていると思います。62KHzで水色が多いのはADPCMが鳴っているところのようですドラムが鳴っているところで、ノイズを出力しているからこうなるようです。22KHzは44KHzよりもややノイズが少ないといえるでしょうか。ということで、ver 2.0では62.5KHzで聴くのが一番いいと思います。

 

このようなノイズの原因はおそらくGAMDX内蔵の周波数変換ルーチンの性能が良くないからと思われます(62KHzのときはAppleの周波数変換ルーチンが使われるのでノイズが乗らないのでしょう)。また、ADPCMはどうしてもGAMDXの内部で周波数変換されるので、高音域ノイズが乗ってしまうようです。

ということで、しばらくGAMDXを改造してAppleの周波数変換ルーチンを使うようにして(フリーの周波数変換ライブラリはいくつかあるのですが、使うのが面倒なのと巨大です(そして著作権の問題も。GPLみたいですが))、音質向上を図ってみます。まずはADPCMからのアップコンバートでノイズが乗らないことを目標にしてみます。

MDX Player 2.0 XCode8 / Swift3へ移植

iOSのMDX Playerに私が追加したMMDSP風の画面をとりこむかたちで、Ver2.0がダウンロード可能となりました。僅かな貢献ですが楽しんでもらえれば幸いです。

MDX Player 2.0.0 – NagisaWorks Blog

ほかに私の追加した変更もGitHubで公開してあったのですが、Ver2.0は他にも大幅な変更(Swiftへの移植)などあり、うまく変更を取り込むことができない・・・ということで私のバージョンは一旦GitHubからは消して(手元にはありますよ)、あらたにForkしてしまいました。

そしてさてダウンロードしてみると・・XCode7用になっているようです。SSDをケチって128GBにした私のメインマシンではXCode8との両立もきついし、これからはSwift3.0が主流になっていくとも思いましたので変更してみました。

一番大変なのはCocoapodsで、SwiftyDropboxというライブラリを使っているのですが初めはインストールされるのがどうしてもSwift2対応のver3.2.0になってしまいました。

sudo gem install cocoapods –pre

としてプレリリース版にしてみたのと、Podfileの初めの行を

platform :ios, ‘9.0’

としてみたところ(他にも色々やったかもしれないが)、ver4.0がインストールされました。メインのソースは自動変更の上でエラー部分だけちょいと直してとりあえず対応終了。ちゃんと動くようです。

追記:他のマシンにGitHubから持ってきた場合、pod installしてできたファイルは不完全で、設定ファイルのpods>(AlamoFire,SwiftyDropbox)>Build setting のUse Legacy Swift Language Version という項目をNoにしないといけないようです。Podfileの変更でなんとかできそうですが。

これからバグフィックス・機能追加考えてみます。