iOSデバイスのマイクをAVFoundationで制御し録音機能を使えるようになりました。さらにSwiftUI標準のグラフ表示ライブラリであるChartsも学んだので、いよいよ録音した結果をグラフ表示させたいと思います。ここでは録音やグラフ表示に必要なSwiftUIコードを紹介します。
こんにちは。wat(@watlablog)です。ここでは今まで学んだことを組み合わせて、iOSデバイスへ録音データをグラフ表示させる方法を紹介します!
事前準備
Swiftの基本文法
Swift言語で色々なアプリを作るためには、最低限の構造体や関数、制御構文や分岐構文を覚えておく必要があります(それ以外は必要になった時に都度覚えれば良いかと)。
この記事を書いている筆者もSwiftを始めたばかりの初心者であるため、まずは以下の記事で簡単に基礎をまとめました。同じく初心者の人に参考になると思いますので是非ご覧ください。
・Swift入門:最低限覚えておく基礎文法の備忘録
Chartsの使い方
SwiftUIには2022年後半にグラフ描画の標準ライブラリとして追加されたChartsがあります。標準なので特にインストールは必要なく、import Chartsで使えます。
Pythonに慣れた筆者としてはなかなか扱いづらいのですが、公式ページとChatGPT-4の助けを借りながらなんとか使い方を以下の記事にまとめました。
・SwiftUI:iOS16から追加されたChartsでグラフを作成
AVFoundationの使い方
録音機能の実装にはAVFoundationを使います。コーディングは上記記事に比べChatGPT-4の力を借りる比率が高くなってきた内容ですが、以下の記事に使い方をまとめました。
・SwiftでiOSデバイスのマイクを使って録音機能を追加する方法
紹介した上記記事ではボタンを使って録音開始と停止を行い、wavファイルを使って波形を確認してきましたが、このページではようやくChartsを使ったグラフ表示と組み合わせてみます。
AVFoundationで録音したデータをChartsで表示するコード例
動作環境
この記事のコードは以下の環境で動作確認を行いました。Chartsを使う時にXcodeのバージョンについて注意点がありました。エラーが出る方は先ほど紹介したChartsの記事をもう一度参照してください。
Mac | OS | macOS Ventura 13.2.1 |
---|---|---|
CPU | 1.4[GHz] | |
メモリ | 8[GB] | |
Xcode | Version | 14.3(14E222b) |
Swift | Version | 5.8(swiftlang-5.8.0.124.2 clang-1403.0.22.11.100) |
iPhone SE2 | OS | iOS 16.3.1 |
全コード
以下に全コードを示します。サンプリングレートを変数として宣言して録音とChartsの両方で共用したり、先ほど紹介した記事とは少し異なりますが基本的には組み合わせです。新規の内容は後述します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
import SwiftUI import Charts import AVFoundation class Recorder: NSObject, ObservableObject { // 録音クラス // オプショナル型としてAVAudioRecoderを定義 var audioRecorder: AVAudioRecorder? // 録音の状態を管理するプロパティ @Published var isRecording = false // 録音データの変数を宣言 @Published var waveformData: [Float] = [] // サンプリングレート var sampleRate: Double = 4096 // カスタムクラスのコンストラクタを定義 override init() { super.init() setUpAudioRecorder() } private func setUpAudioRecorder() { // 録音の設定 let recordingSession = AVAudioSession.sharedInstance() // エラーを確認 do { try recordingSession.setCategory(.playAndRecord, mode: .default) try recordingSession.setActive(true) // 辞書型で設定値を変更 let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatLinearPCM), AVSampleRateKey: sampleRate, AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsBigEndianKey: false, AVLinearPCMIsFloatKey: false, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] // wavファイルのパスを設定する(.wavはリアルタイムに書き込まれる) let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioFileURL = documentsPath.appendingPathComponent("recording.wav") audioRecorder = try AVAudioRecorder(url: audioFileURL, settings: settings) // audioRecorderがnilでない場合のみバッファ割当てや初期化、設定をする audioRecorder?.prepareToRecord() } // エラーの場合 catch { print("Error setting up audio recorder: \(error)") } } func startRecording() { // 録音するメソッド audioRecorder?.record() isRecording = true } func stopRecording() { // 録音停止するメソッド audioRecorder?.stop() isRecording = false // 録音停止時にwavファイルのパスをコンソールに表示する if let audioFileURL = audioRecorder?.url { print(audioFileURL)} // 配列データとして取得する getWaveformData { waveformData in print("wave length=", waveformData.count) self.waveformData = waveformData } } func getWaveformData(completion: @escaping ([Float]) -> Void) { // 録音結果の配列データを取得するメソッド guard let audioFileURL = audioRecorder?.url else { return } do { let audioFile = try AVAudioFile(forReading: audioFileURL) let audioFormat = AVAudioFormat(standardFormatWithSampleRate: audioFile.processingFormat.sampleRate, channels: audioFile.processingFormat.channelCount) let audioBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat!, frameCapacity: UInt32(audioFile.length)) try audioFile.read(into: audioBuffer!) let floatArray = Array(UnsafeBufferPointer(start: audioBuffer!.floatChannelData![0], count: Int(audioBuffer!.frameLength))) completion(floatArray) } catch { print("Error getting waveform data: \(error)") } } } struct PointsData: Identifiable { // 点群データの構造体 var xValue: Float var yValue: Float var id = UUID() } struct ContentView: View { // データ構造関係の宣言 @State private var data: [PointsData] = [] @State private var x: [Float] = [] @State private var y: [Float] = [] // 録音関係の宣言 @StateObject private var recorder = Recorder() private var dt: Float { Float(1.0 / recorder.sampleRate) } @State private var isDisplayingData = false var body: some View { // UI VStack{ Chart { // データ構造からx, y値を取得して散布図プロット ForEach(data) { shape in // 折れ線グラフをプロット LineMark( x: .value("x", shape.xValue), y: .value("y", shape.yValue) ) } } .padding(.all, 10) if recorder.isRecording { // 録音している時 Button(action: { // 停止ボタンが押されたらデータをChartsに表示させる // 録音の実行 print("Stop") recorder.stopRecording() // バックグラウンドでデータ処理を行う DispatchQueue.global(qos: .userInitiated).async { // データ取得 y = recorder.waveformData let samplePoints = Float(y.count) x = Array(stride(from: 0.0, to: samplePoints * dt, by: dt)) // プロットデータの追加 data.removeAll() // メインと同期させる DispatchQueue.main.async { data = zip(x, y).map { PointsData(xValue: $0, yValue: $1) } isDisplayingData = false } } }) { Text("Stop Recording") .padding() .background(Color.red) .foregroundColor(Color.white) .cornerRadius(10) .padding(.all, 10) } } else { // 録音していない時 Button(action: { print("Start") isDisplayingData = true recorder.startRecording() }) { Text("Start Recording") .padding() .background(Color.green) .foregroundColor(Color.white) .cornerRadius(10) .padding(.all, 10) } .opacity(isDisplayingData ? 0.5 : 1.0) .disabled(isDisplayingData) } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { // プレビュー ContentView() } } |
詰まったところ
処理が遅い→処理のスレッドを分けた
Pythonではmatplotlibを用いて直接配列データを可視化できました。これが使いやすかったのですがSwiftとChartsでは自分で構造体のデータ型を定義して(xValueとyValue)、どんな配列サイズでも対応できるよう動的にデータを.mapする必要がありました。
.mapも結局は繰り返し処理でありforと同じです。forループはPythonほどでないにしてもCと比べると遅いです。できれば使いたくないですね。
少しだけ高速化する方法として、データ処理は別スレッドで処理するようにしてみました。「DispatchQueue.global(qos: .userInitiated).async {}」と「DispatchQueue.main.async {}」で非同期処理ができるとのことです。
まだ理解が追いついていない感覚ですが、調べたところこちらのページがわかりやすいと思いました。
・Swiftでの非同期処理GDP|ディスパッチキューの解説|DispatchQueue.globalとmain
ボタンの有効化と無効化のタイミング
このアプリはStart RecordingをクリックしたらボタンがStop Recordingに変わり、Stop Recordingをクリックすると録音が停止され文字も元に戻ります。しかしグラフ描画処理にやや時間がかかっており、まだグラフが更新されていないのにStart Recordingを押せてしまいます。
ボタンは.opacityで透明化、.disabledで無効化が可能なため、組み合わせれば視覚的にも機能的にもボタンをユーザーにクリックさせないようにできます。しかし「グラフが更新し終わった時」というタイミングに適切に再度クリック可能な状態に戻す必要があり、このタイミング調査がやや大変でした。
結果的にはisDisplayingDataという変数を用意してプログラム内でtrueとfalseを切り替えたり、.opacityや.disabledの引数に渡したりすることでうまくいったような動作をしています(詳細なデバッグはできていません…)。
録音データをChartsに渡す
結果的にはChatGPT-4に書いてもらったのですが、この部分が最もやっかいだと思います。自分では書けない…。多分本職のプログラマからするともっと良い書き方があると思います。
completionという無名関数のクロージャを使っていたり色々していますが、この関数は録音されたデータを配列とし、その配列を引数にcompletionを実行することにあります。
実行結果(iPhone実機で収録)
こちらが実行結果の例です。自分のiPhone端末にアプリをインストールして実機テストを行ってみました(画面上部に「収録」とありますが、これはiPhone実機で動画を撮ったからです)。画面の縦横変換もスムーズです。
ちなみにシミュレータ上で動かすと、僕のPCでは処理がめちゃくちゃ遅くなりました。シミュレータに割り当てられたリソースがしょぼいのか、iPhoneの方がサクサク動きます。
サンプリングレートは4096となっていますが、ここをコード上で12800、44100(Hz)等に変更することでより細かい時間刻みの録音が可能です。
FFT分析をするのであれば、分析したい周波数の2.56倍を設定しておくと良いでしょう。この辺の信号処理的な話は「信号処理」カテゴリを参照してください。
まとめ
今回もChatGPT-4先生におおまかにプログラムを作ってもらい、それを少しずつ紐解いていったり修正したりしてコーディングを行いました。そのため筆者自身はまだまだ初心者の感覚です。
この記事ではこれまで覚えた録音機能とグラフ表示機能を組み合わせたSwiftコードを紹介しました。
AVFoundationやChartsの使い方について、少しでも参考になれば幸いです。録音データをグラフ表示させる…Pythonでは何気なくできたものですが、Swiftだとまだまだ難しいですね。
でも自分のモバイル端末で実際にプログラムが走っているのを見ることができ、久々にワクワク感がこみ上げてきました!
いっぱしのアプリになるまでいろいろいじってみます。
録音機能がそれっぽいアプリになりました!難しいけど面白い…そんな感覚!
Twitterでも関連情報をつぶやいているので、wat(@watlablog)のフォローお待ちしています!