xyk blog

最近は iOS 開発の記事が多めです。

iPhone のマイクから拾った音の音程を判定する

環境: Xcode10.3、Swift 5.0.1

iPhone のマイクから拾ったオーディオ情報から音程を判定する方法について調べた。
ちゃんとやるには、離散フーリエ変換 (discrete Fourier transform) を使って周波数を算出するらしいのだけれども、今回はAudioKitというOSSライブラリを使うことで簡単に実現できたのでメモ。

github.com

やりたいことがそのまま公式Example(GitHubコードはこちら)として実装されていたのでこの通りに進めた。

audiokit.io

画面にはマイクから拾った音の周波数(Frequency)と音階(Note)、また波形がリアルタイムで表示される。

AudioKit インストール

プリコンパイル済みのFrameworkはこちらからダウンロードできる。
プロジェクトにダウンロードしたAudioKit.frameworkを追加、
そして TARGET の Build Settings > Linking > Other Linker Flags に -lc++ の追加する。

CocoaPods の場合

Podfile

pod 'AudioKit', '~> 4.0'

インストール

$ pod install

Carthage の場合

Cartfile

github "AudioKit/AudioKit"

ビルド

# iOS
$ carthage update --platform iOS --no-use-binaries --cache-builds --new-resolver
# Mac
$ carthage update --platform Mac --no-use-binaries --cache-builds --new-resolver

さらに TARGET の Build Settings > Linking > Other Linker Flags に -lc++ の追加が必要。

Swift コード

Exampleのコードそのまま。今回は波形表示は不要だったので省いた。
AKFrequencyTrackerクラスを利用することでピッチ検出することができる。
ただし、現状は検出できるのは単音(モノフォニック)のみで、和音のような複数音(ポリフォニック)には対応していないようだ。

import UIKit
import AudioKit

class ViewController: UIViewController {

    @IBOutlet weak var frequencyLabel: UILabel!
    @IBOutlet weak var amplitudeLabel: UILabel!
    @IBOutlet weak var noteNameWithSharpsLabel: UILabel!
    @IBOutlet weak var noteNameWithFlatsLabel: UILabel!
    
    var mic: AKMicrophone!
    var tracker: AKFrequencyTracker!
    var silence: AKBooster!
    
    let noteFrequencies = [16.35, 17.32, 18.35, 19.45, 20.6, 21.83, 23.12, 24.5, 25.96, 27.5, 29.14, 30.87]
    let noteNamesWithSharps = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
    let noteNamesWithFlats = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        AKSettings.audioInputEnabled = true
        mic = AKMicrophone()
        tracker = AKFrequencyTracker(mic)
        silence = AKBooster(tracker, gain: 0)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        AudioKit.output = silence
        do {
            try AudioKit.start()
        } catch {
            AKLog("AudioKit did not start!")
        }
        Timer.scheduledTimer(timeInterval: 0.1,
                             target: self,
                             selector: #selector(ViewController.updateUI),
                             userInfo: nil,
                             repeats: true)
    }
    
    @objc func updateUI() {
        if tracker.amplitude > 0.1 {
            frequencyLabel.text = String(format: "%0.1f", tracker.frequency)
            
            var frequency = Float(tracker.frequency)
            while frequency > Float(noteFrequencies[noteFrequencies.count - 1]) {
                frequency /= 2.0
            }
            while frequency < Float(noteFrequencies[0]) {
                frequency *= 2.0
            }
            
            var minDistance: Float = 10_000.0
            var index = 0
            
            for i in 0..<noteFrequencies.count {
                let distance = fabsf(Float(noteFrequencies[i]) - frequency)
                if distance < minDistance {
                    index = i
                    minDistance = distance
                }
            }
            let octave = Int(log2f(Float(tracker.frequency) / frequency))
            noteNameWithSharpsLabel.text = "\(noteNamesWithSharps[index])\(octave)"
            noteNameWithFlatsLabel.text = "\(noteNamesWithFlats[index])\(octave)"
        }
        amplitudeLabel.text = String(format: "%0.2f", tracker.amplitude)
    }
}

コード内にあるnoteFrequencies配列の数値の意味がわからなかったが、調べたところ音高(Pitch)の周波数らしい。
オクターブ 4 のラの音A4 = 440Hzを基準音とすると以下の表のようになる。
1オクターブ高いと周波数は2倍になる。

Frequency (Hz)
OctaveNote
C C# D Eb E F F# G G# A Bb B
0 16.35 17.32 18.35 19.45 20.60 21.83 23.12 24.50 25.96 27.50 29.14 30.87
1 32.70 34.65 36.71 38.89 41.20 43.65 46.25 49.00 51.91 55.00 58.27 61.74
2 65.41 69.30 73.42 77.78 82.41 87.31 92.50 98.00 103.8 110.0 116.5 123.5
3 130.8 138.6 146.8 155.6 164.8 174.6 185.0 196.0 207.7 220.0 233.1 246.9
4 261.6 277.2 293.7 311.1 329.6 349.2 370.0 392.0 415.3 440.0 466.2 493.9
5 523.3 554.4 587.3 622.3 659.3 698.5 740.0 784.0 830.6 880.0 932.3 987.8
6 1047 1109 1175 1245 1319 1397 1480 1568 1661 1760 1865 1976
7 2093 2217 2349 2489 2637 2794 2960 3136 3322 3520 3729 3951
8 4186 4435 4699 4978 5274 5588 5920 6272 6645 7040 7459 7902

ピアノの鍵盤数は88で7オクターブ、この表での範囲はA0 = 27.50HzからC8 = 4186Hzになる。
ちなみに日本のピアノはA4 = 442Hzで調律されることが多いとのこと。

https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Piano_Frequencies.svg/520px-Piano_Frequencies.svg.png

水色が真ん中のド (C4)、英語では middle C と言う、黄色がラ (A4)

参考:

A440 - Wikipedia

HUGO で静的なWebページを作成し Firebase Hosting にデプロイする

gohugo.io

こちらの公式ドキュメントの Quick Start に従ってやってみる。

HUGO インストール

$ brew install hugo

インストール確認

$ hugo version
Hugo Static Site Generator v0.58.1/extended darwin/amd64 BuildDate: unknown

WEBサイトを生成

新しいWEBサイトを作成する。
これで quickstart ディレクトリが作成される。

$ hugo new site quickstart

$ tree -L 2 quickstart
quickstart
├── archetypes
│   └── default.md
├── config.toml
├── content
│   └── posts
├── data
├── layouts
├── public
│   ├── 404.html
│   ├── categories
│   ├── dist
│   ├── images
│   ├── index.html
│   ├── index.xml
│   ├── sitemap.xml
│   └── tags
├── resources
│   └── _gen
├── static
└── themes

テーマの追加

次にテーマの設定を行う。
テーマ一覧はこちら
今回は Ananke theme を利用する。
まず git の設定を行い、themes ディレクトリ内に submodule で追加する。

$ cd quickstart
$ git init
$ git submodule add https://github.com/budparr/gohugo-theme-ananke.git themes/ananke

そして設定ファイル config.toml にテーマを追加。

$ echo 'theme = "ananke"' >> config.toml

コンテンツの追加

とりあえず適当に最初のコンテンツを追加してみる。

$ hugo new posts/my-first-post.md

これで content ディレクトリに Markdown のファイルが作成される。
中身はデフォルトでこんなかんじ。

$ cat content/posts/my-first-post.md created
---
title: "My First Post"
date: 2019-09-13T16:52:17+09:00
draft: true
---

ここにある draft が true だと下書き状態となる。
下書き状態だと静的ファイルとして書き出す時に対象にならない。

draft: falseに変更し、コンテンツの中身はその下に書いていく。

---
title: "My First Post"
date: 2019-09-13T16:52:17+09:00
draft: false
---

## Hello

こんにちは。

ちなみにデフォルトでdraft: falseにしたい場合は、archetypes/default.md から設定できる。

$ cat archetypes/default.md
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
---

ローカルサーバを起動して動作確認

ローカル環境でサーバを起動し、動作確認する。
-D で下書き状態の記事も表示することができる。

$ hugo server -D

                   | EN
+------------------+----+
  Pages            |  4
  Paginator pages  |  0
  Non-page files   |  0
  Static files     |  0
  Processed images |  0
  Aliases          |  0
  Sitemaps         |  1
  Cleaned          |  0
---

Environment: "development"
Serving pages from memory
Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender
Web Server is available at http://localhost:1313/ (bind address 127.0.0.1)
Press Ctrl+C to stop

http://localhost:1313/
で確認。

f:id:xyk:20190913172955p:plain

サイトの全体設定

サイトのURLやタイトルなどの設定は config.toml で行う。

静的ファイルの書き出し

hugoコマンドを実行することで、public ディレクトリに 静的ファイルが作成される。
コンテンツファイルでdraft: falseになっていると書き出し対象にならず、何も出力されないので注意。

$ hugo

                   | EN
+------------------+----+
  Pages            |  4
  Paginator pages  |  0
  Non-page files   |  0
  Static files     |  0
  Processed images |  0
  Aliases          |  0
  Sitemaps         |  1
  Cleaned          |  0

Firebase Hosting にデプロイする

Firebase CLI はインストール済み。
そして Firebase Console からプロジェクトも作成済み。

Firebaseプロジェクトを作成。

$ firebase init

Hosting を選択

? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, t
hen Enter to confirm your choices.
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
❯◉ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules

既存プロジェクトを選択

? Please select an option: (Use arrow keys)
❯ Use an existing project
  Create a new project
  Add Firebase to an existing Google Cloud Platform project
  Don't set up a default project

? Please select an option: Use an existing project
? Select a default Firebase project for this directory: (Use arrow keys)
❯ myProject (myProject)

Publish ディレクトリにデフォルトの public を選択。
Firebase Hosting は HUGO と同じ public ディレクトリを対象とするので、ここはそのまま Enter で進む。

=== Hosting Setup
 
Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.
 
? What do you want to use as your public directory? public

シングルページかと聞かれるので No にする。
すると、public/index.html が上書きされてしまう。

? Configure as a single-page app (rewrite all urls to /index.html)? No
✔  Wrote public/404.html
✔  Wrote public/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...
i  Writing gitignore file to .gitignore...

✔  Firebase initialization complete!

再度hugo コマンドで書き出す。

$ hugo

そしてデプロイする。

$ firebase deploy

Firebase Cloud Messaging によるプッシュ通知をバックグラウンドでも受け取る

Swift version 5.0.1
iOS11

Firebase Cloud Messaging でアプリがバックグラウンドでもプッシュ通知を受け取る方法について。
APNs ペイロードcontent-available: 1を含める必要あり。
これが含まれることでアプリがバックグラウンドであっても
application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
メソッドが呼ばれるようになる。またフォアグラウンドでも同様に呼ばれる。
この引数のクロージャであるfetchCompletionHandlerはメソッドの開始から 30 秒以内に実行する必要がある。
アプリが未起動の場合にはこのメソッドは呼ばれない。

Xcode 設定

Xcode

  • Capabilities -> Background Modes -> Remote notifications
  • Capabilities -> Push Notifications

にチェックを入れる。

f:id:xyk:20190830161336p:plain f:id:xyk:20190830161205p:plain

FCM Token 取得コード

import Firebase

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        return true
    }
}

extension AppDelegate: MessagingDelegate {
    // FCMトークン取得
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) {
        print("Firebase registration token: \(fcmToken)")
    }
}

通知受信時のハンドリング

// AppDelegate
    func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        if let foo = userInfo["foo"] as? String {
            print("foo: \(foo)") // -> bar
        }
        completionHandler(.newData)
    }

cURL で疎通確認

$ curl --header "Content-Type: application/json" \
--header "Authorization: key={サーバーキー}" \
https://fcm.googleapis.com/fcm/send \
-d '{"notification": {"body": "Hello from curl via FCM!", "sound": "default"},
"priority": "high",
"content_available": true,
"data" {
  "foo": "bar"
},
"to": "{FCMトークン}"}'

サーバーキーは FIrebase 管理画面から取得、FCMトークンは上記 Delegate で取得したトークン。

f:id:xyk:20190829154049p:plain

content_available パラメータは必須。

Firebase Cloud Messaging の HTTP プロトコル

iOS では、このフィールドを使用して APNs ペイロードで content-available を表します。通知やメッセージの送信時にこのフィールドが true に設定されている場合、非アクティブなクライアント アプリのスリープ状態が解除されます。また、メッセージは FCM 接続サーバーではなく APNs を介して、サイレント通知として送信されます。APNs のサイレント通知の配信は保証されておらず、ユーザーが [低電力モード] をオンにする、アプリを強制終了するなどの要因によって結果が異なる場合があることに注意してください。

data パラメータにはカスタムのデータを入れられる。


サイレントプッシュ通知について

上のバックグラウンド受信と同様に、content-available キーに 1 を指定し、かつ alert.bodysoundbadge の各キーを除外することでサイレントプッシュ通知となる。
サイレントプッシュ通知を受信すると、通知センターは表示されず application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
が呼び出される。
通知センターは表示しないのでユーザに通知許諾を得ずとも使用可能。

cURLでサイレントプッシュ通知

$ curl --header "Content-Type: application/json" \
--header "Authorization: key={サーバーキー}" \
https://fcm.googleapis.com/fcm/send \
-d '{
"priority": "high",
"content_available": true,
"data" {
  "foo": "bar"
},
"to": "{FCMトークン}"}'

参考:
Google Developers Japan: iOS で Firebase Cloud Messaging をデバッグする
https://developers-jp.googleblog.com/2017/02/debugging-firebase-cloud-messaging-on.html
プッシュメッセージのカスタマイズ
https://docs.kii.com/ja/guides/cloudsdk/ios/managing-push-notification/push-techinology/message-customize/

App Store Connect にアップロードするスクリーンショットのサイズについて

App Store Connect にアップロードするスクリーンショットの仕様について。
2019年8月現在、iPhone 向けアプリには

  • 6.5インチ - 1242 x 2688ピクセル
    • iPhone XS Max
  • 5.5インチ - 1242 x 2208ピクセル
    • iPhone 8 Plus
    • iPhone 7 Plus
    • iPhone 6s Plus

の2サイズのスクリーンショットが必須。

以下、App Store Connect のキャプチャ。

f:id:xyk:20190824193801p:plain
6.5インチ

f:id:xyk:20190824193817p:plain
5.5インチ

f:id:xyk:20190824193826p:plain
スクリーンショット仕様

https://help.apple.com/app-store-connect/#/devd274dd925

CGAffineTransform を使い View を180度回転、元に戻す

環境: Swift 5.0.1

ある View (ここではUIImageView)を180度回転させる、そして元の状態に戻すアニメーションをさせたい。
こういう場合、 CGAffineTransform の rotation を使えば実現できる。
f:id:xyk:20190717114639g:plain:w80

こんなかんじの動きにしたかったのだが少しハマってしまった。
最初は以下のように実装した。

UIView.animate(withDuration: 0.5) {
    if self.arrowUpImageView.transform.isIdentity {
        self.arrowUpImageView.transform = CGAffineTransform(rotationAngle: .pi)
    } else {
        self.arrowUpImageView.transform = .identity
    }
}

しかしこれだと上画像のように逆回転せず下画像のように同方向の回転で戻ってしまう。

f:id:xyk:20190717114430g:plain:w80

そこで以下のように180度に満たないangleにすることで意図通り逆回転で戻ったので、とりあえずこれでよしとする。

UIView.animate(withDuration: 0.5) {
    if self.arrowUpImageView.transform.isIdentity {
        self.arrowUpImageView.transform = CGAffineTransform(rotationAngle: .pi * 0.999)
    } else {
        self.arrowUpImageView.transform = .identity
    }
}

f:id:xyk:20190717114639g:plain:w80

UILabelのattributedTextのテキストを他の属性は変更せずに別の文字列に置き換える

環境: Swift5.0

UILabelのattributedTextのテキストを他の属性は変更せずに別の文字列に置き換える例。

@IBOutlet weak var textLabel: UILabel!

private func setupUI() {
    if let attrStr = textLabel.attributedText?.mutableCopy() as? NSMutableAttributedString {
        attrStr.mutableString.setString("置き換える文字列")
        textLabel.attributedText = attrStr
    }
}

ちなみに文字列の一部分を置き換える場合は以下で書いた。

xyk.hatenablog.com

MacのDockアイコンがおかしいときにやったこと

MacのDock上に表示されるアプリのアイコンがデフォルトと言うか generic なアイコンになってしまった。
元に戻すためにやった手順をメモ。
先に結論を書いておくとアイコンのキャッシュを削除することで元に戻った。

まずSIP(System Integrity Protection)を無効(disable)にしておく必要がある。
Command + R を押しながらMac起動し、リカバリーモードで起動する。
リカバリーモードが起動したら、ユーティリティからターミナルを起動し

$ csrutil status

で現在の状態を確認、enabled であれば

$ csrutil disable

を実行する。
その後、Macを再起動。
そして、以下コマンドでキャッシュ削除を行う。

$ sudo find /private/var/folders/ -name 'com.apple.dock.iconcache' -delete
$ sudo find /private/var/folders/ -name 'com.apple.iconservices' -delete
$ sudo rm -r /Library/Caches/com.apple.iconservices.store
$ killall Dock

UISegmentedControl を未選択状態にする

selectedSegmentIndex に UISegmentedControlNoSegment (-1) を設定する。
この定数を忘れるのでメモ。

segmentedControl.selectedSegmentIndex = UISegmentedControlNoSegment

Swift4 から

segmentedControl.selectedSegmentIndex = UISegmentedControl.noSegment

UIColorの色を暗くする

Swift 4.2.1

現在の色から暗めの色に明度(brightness/lightness)を変更するための UIColor Extension。

extension UIColor {
    
    func dark(brightnessRatio: CGFloat = 0.8) -> UIColor {
        var hue: CGFloat = 0
        var saturation: CGFloat = 0
        var brightness: CGFloat = 0
        var alpha: CGFloat = 0
        
        if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
            return UIColor(hue: hue, saturation: saturation, brightness: brightness * brightnessRatio, alpha: alpha)
        } else {
            return self
        }
    }
}

使い方

view.backgroundColor = view.backgroundColor.dark()

複数の CLLocationManager を使う

Swift 4.2.1
Deployment Target: 9.0

CoreLocation で位置情報を取得する際に、複数のCLLocationManagerインスタンスを同時に立ち上げて動かしたときにどうなるか気になったので試してみた。
結論としては、それぞれのインスタンスが影響し合うことなく別々に制御(startやstop) できた。

以下検証コード。

import UIKit
import CoreLocation

class ViewController: UIViewController {

    private let locationServiceA = LocationService.init(tag: "A")
    private let locationServiceB = LocationService.init(tag: "B")
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func handleStartButtonA(_ sender: UIButton) {
        locationServiceA.startUpdating()
    }
    @IBAction func handleStartButtonB(_ sender: UIButton) {
        locationServiceB.startUpdating()
    }
    @IBAction func handleStopButtonA(_ sender: UIButton) {
        locationServiceA.stopUpdating()
    }
    @IBAction func handleStopButtonB(_ sender: UIButton) {
        locationServiceB.stopUpdating()
    }
}

class LocationService: NSObject {
    private let locationManager = CLLocationManager()
    private var tag = ""
    
    override init() {
        super.init()
        locationManager.delegate = self
    }
    
    convenience init(tag: String) {
        self.init()
        self.tag = tag
    }
    
    func startUpdating() {
        print("tag:\(tag) startUpdating")
        locationManager.startUpdatingLocation()
    }
    
    func stopUpdating() {
        print("tag:\(tag) stopUpdating")
        locationManager.stopUpdatingLocation()
    }
    
    func checkPermisson() {
        switch CLLocationManager.authorizationStatus() {
        case .notDetermined:
            print("notDetermined")
        case .restricted:
            print("restricted")
        case .denied:
            print("denied")
        case .authorizedAlways:
            print("authorizedAlways")
        case .authorizedWhenInUse:
            print("authorizedWhenInUse")
        }
    }
}

extension LocationService: CLLocationManagerDelegate {
    
    //  このメソッドは locationManager.delegate = self を実行したタイミングでまず1回呼ばれる
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        print("tag:\(tag) didChangeAuthorization -> ", status.rawValue)
        if status == .notDetermined {
            locationManager.requestWhenInUseAuthorization()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            let latitude = location.coordinate.latitude
            let longitude = location.coordinate.longitude
            let timestamp = location.timestamp.description
            print("tag:\(tag) didUpdateLocations -> latitude:\(latitude) longitude:\(longitude) timestamp:\(timestamp)")
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("tag:\(tag) didFailWithError -> ", error)
    }
}

Info.plist に以下追加

アプリ使用時のみ位置情報を取得する場合

<key>NSLocationWhenInUseUsageDescription</key>
<string>位置情報を確認するために利用します。</string>

常に位置情報を取得する場合

<key>NSLocationAlwaysUsageDescription</key>
<string>位置情報を確認するために利用します。</string>