【iOS】Whisperで文字起こししてみた
IT技術
はじめに
Whisper を使った文字起こしアプリを実装してみたのですが、導入や実装で時間がかかってしまったため、今回、備忘録として導入から実際に音声の文字起こしまでをまとめたいと思います。
Whisper とは
Whisperは汎用的な音声認識モデルです。多様な音声を収録した大規模なデータセットで学習されており、多言語音声認識、音声翻訳、言語識別を実行できるマルチタスクモデルでもあります。
引用:https://github.com/openai/whisper/blob/main/README.md#whisper
Whisper は MIT ライセンスになっており、無料で使用することができるようになっています。
今回、この Whisper を使って音声の文字起こしをやってみました。
実装
whiper.cpp の導入から実際に文字起こしするところまで実装します。
導入
whisper.xcframework と whisper.cpp.swift と Whisper のモデル をプロジェクトに追加します。
whisper.cpp からコードを clone またはダウンロードしてきます。
ダウンロードできたら、whisper.cpp のルートディレクトリで以下のコマンドを実行し、whisper.xcframework を作成します。
1/build-xcframework.sh実行が終わると whisper.cpp/build-apple/フォルダに whisper.xcframework が作成されるので、whisper.xcframework のフォルダごと自分のプロジェクトのフォルダに移動させて、Xcode > TARGETS > General > Frameworks,Libraries, and Embedded Content で whisper.xcframework を 追加して Embed & Sign に変更すると完了です。
whisper.cpp.swift は whisper.cpp/examples/whisper.swiftui/ フォルダにあるので、whisper.cpp.swift のフォルダごと自分のプロジェクトのフォルダに移動させて、Xcode にドラッグ&ドロップで追加します。
Whisper のモデルは whisper.cpp の models ディレクトリで以下のコマンドを実行します。
今回は small-q8_0 モデルを作成しました。
1./download-ggml-model.sh small-q8_0モデルの作成ができたら、自分のプロジェクトに Resources/models/ フォルダを作成し、その中に作成したモデルファイルを入れます。
参考:https://github.com/ggml-org/whisper.cpp/blob/master/examples/whisper.swiftui/README.md
画面の実装とレコーディング処理の実装
録音したデータから文字起こしを行うため、録音の処理と文字起こししたテキストを表示する画面を実装します。
録音処理は RPScreenRecorder を使用して実装しました。
録音開始のメソッドと録音停止のメソッドを準備して、録音中のフラグと文字起こししたテキストを状態として持つようにしています。
1// TranscriptionRecorder.swift
2
3import AVFoundation
4import ReplayKit
5
6@MainActor
7final class TranscriptionRecorder: ObservableObject {
8 @Published var isRecording = false
9 @Published var transcriptText = "文字起こしを開始するには「Start」ボタンを押してください。"
10
11 func start() {
12 guard !isRecording else { return }
13 isRecording = true
14 transcriptText = ""
15
16 RPScreenRecorder.shared().isMicrophoneEnabled = true
17 RPScreenRecorder.shared().startCapture(handler: { [weak self] sampleBuffer, sampleBufferType, error in
18 guard let self = self else { return }
19
20 if let error {
21 print("Capture handler error:", error)
22 return
23 }
24
25 // マイク音声のみ処理
26 if sampleBufferType == .audioMic {
27 // TODO: 音声データの文字起こしをする
28 }
29 }, completionHandler: { [weak self] error in
30 guard let self = self else { return }
31
32 if let error {
33 print("completionHandler error:", error)
34 Task { @MainActor in
35 self.isRecording = false // エラー終了時もフラグをリセット
36 }
37 return
38 }
39 })
40 }
41
42 func stop() {
43 guard isRecording else { return }
44
45 RPScreenRecorder.shared().stopCapture { [weak self] error in
46 if let error { print("stop error:", error) }
47 Task { @MainActor in
48 guard let self = self else { return }
49
50 self.isRecording = false
51 }
52 }
53 }
54}
画面は開始と停止ボタン、文字起こしした文字を表示するスクロールビューというシンプルな画面にしました。
開始ボタンで録音の開始メソッドを呼び出し、停止ボタンで録音の停止メソッドを呼び出します。
また、録音中のフラグでボタンの活性・非活性を切り替えるようにし、文字起こしした文字をスクロールビューに表示します。
1// ContentView.swift
2
3import SwiftUI
4
5struct ContentView: View {
6 @StateObject var transcriptionRecorder = TranscriptionRecorder()
7
8 var body: some View {
9 VStack(spacing: 16) {
10 // レコーディングの開始と停止ボタン
11 HStack {
12 Button {
13 transcriptionRecorder.start()
14 } label: {
15 Label("Start", systemImage: "record.circle")
16 }
17 .disabled(transcriptionRecorder.isRecording)
18 Spacer()
19 Button {
20 transcriptionRecorder.stop()
21 } label: {
22 Label("Stop", systemImage: "stop.circle")
23 }
24 .disabled(!transcriptionRecorder.isRecording)
25 }
26 .padding(.horizontal)
27 Divider()
28 // スクロール可能なテキスト表示領域
29 ScrollView {
30 Text(transcriptionRecorder.transcriptText)
31 .padding()
32 .frame(maxWidth: .infinity, alignment: .leading)
33 }
34 .background(Color.gray.opacity(0.1))
35 .cornerRadius(8)
36 .padding(.horizontal)
37 }
38 }
39}
40
41#Preview {
42 ContentView()
43}
録音データを Data 型へ変換
録音データを Whisper に渡して文字起こしする際、音声データは PCM(16-bit)/16kHz/モノラル(1ch)形式を使用します。
RPScreenRecorder で録音した CMSampleBuffer を PCM(16-bit)/16kHz/モノラル(1ch)に変換して、さらに Data 型へ変換します。
CMSampleBuffer を Data へ変換する CMSampleBufferConverter を実装しました。
1// CMSampleBufferConverter.swift
2
3import AVFoundation
4import CoreMedia
5
6enum CMSampleBufferConverterError: Error {
7 case failed(String)
8}
9
10final class CMSampleBufferConverter {
11
12 private let inFormat:AVAudioFormat
13 private let outFormat:AVAudioFormat
14 // リサンプリングを行うコンバータ
15 private let converter: AVAudioConverter
16
17 init(sampleBuffer: CMSampleBuffer, outSampleRate: Double = 16_000) throws {
18 // 入力フォーマットを取得
19 guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else {
20 throw CMSampleBufferConverterError.failed("入力フォーマットの取得に失敗しました")
21 }
22 self.inFormat = AVAudioFormat(cmAudioFormatDescription: formatDesc)
23
24 // 「PCM(16-bit)/16kHz/モノラル(1ch)/非インターリーブ」で出力フォーマットを作成
25 guard let outFormat = AVAudioFormat(
26 commonFormat: .pcmFormatInt16,
27 sampleRate: outSampleRate,
28 channels: 1,
29 interleaved: false
30 ) else {
31 throw CMSampleBufferConverterError.failed("出力フォーマットの作成に失敗しました")
32 }
33 self.outFormat = outFormat
34
35 // コンバータを作成
36 guard let converter = AVAudioConverter(from: inFormat, to: outFormat) else {
37 throw CMSampleBufferConverterError.failed("コンバータ作成に失敗しました")
38 }
39 self.converter = converter
40 }
41
42 // CMSampleBufferをAVAudioPCMBufferに変換して返す
43 private func convertSampleBufferToPCMBuffer(sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? {
44 var blockBuffer: CMBlockBuffer?
45 // CMSampleBufferから取り出したAudioBufferListを保持するための変数
46 var audioBufferList = AudioBufferList()
47 CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
48 sampleBuffer,
49 bufferListSizeNeededOut: nil,
50 bufferListOut: &audioBufferList,
51 bufferListSize: MemoryLayout.size,
52 blockBufferAllocator: nil,
53 blockBufferMemoryAllocator: nil,
54 flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
55 blockBufferOut: &blockBuffer
56 )
57
58 // 1buffer辺りのsample数を取得
59 let numSamples = AVAudioFrameCount(sampleBuffer.numSamples)
60 // audioBufferを保持するための変数(この時点では中身は空の状態)
61 guard let buffer = AVAudioPCMBuffer(pcmFormat: inFormat, frameCapacity: numSamples) else {
62 return nil
63 }
64 buffer.frameLength = numSamples
65
66 // AudioBufferListからAVAudioPCMBufferに変換
67 // mData から AVAudioPCMBuffer にコピー
68 if let inPointer = audioBufferList.mBuffers.mData {
69 // CMSampleBufferの生PCMデータをInt16として解釈
70 let inPointerInt16 = inPointer.assumingMemoryBound(to: Int16.self)
71 // AVAudioPCMBuffer が持っている内部の書き込み先 (チャンネル 0)
72 guard let outPointer = buffer.int16ChannelData?[0] else {
73 return nil
74 }
75 // コピーするサンプル数(フレーム数分)
76 let sampleCount = Int(numSamples)
77 // コピー処理
78 outPointer.update(from: inPointerInt16, count: sampleCount)
79 }
80
81 return buffer
82 }
83
84 // AVAudioPCMBufferをDataに変換して返す
85 private func convertAudioBufferToData(audioBuffer: AVAudioPCMBuffer) throws -> Data {
86 guard let channelData = audioBuffer.int16ChannelData else {
87 throw CMSampleBufferConverterError.failed("出力bufferからのint16ChannelData取り出しに失敗しました")
88 }
89 let frameLength = Int(audioBuffer.frameLength)
90 let samples = UnsafeBufferPointer(start: channelData[0], count: frameLength)
91 let data = Data(buffer: samples)
92 return data
93 }
94
95
96 // CMSampleBufferをリサンプリングし、Dataに変換して返す
97 func resampleAndConvert(sampleBuffer: CMSampleBuffer) throws -> Data {
98
99 // リサンプリングの比率を計算(音声長さを入力と出力で合わせるための比率)
100 let ratio = outFormat.sampleRate / inFormat.sampleRate
101 // 出力のフレーム数(1buffer辺りのsample数)を算出
102 let outFrames = AVAudioFrameCount(Double(sampleBuffer.numSamples) * ratio)
103 // 変換したbufferを保持するための変数(この時点では中身は空の状態)
104 guard let outBuffer = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: outFrames) else {
105 throw CMSampleBufferConverterError.failed("出力バッファの作成に失敗しました")
106 }
107
108 // 変換エラーを保持するための変数
109 var error: NSError?
110
111 // 変換を実行し、結果をoutBufferに格納
112 // 入力値はクロージャを通して、コンバータに渡す
113 let outputStatus = converter.convert(to: outBuffer, error: &error) {
114 [unowned self] numberOfFrames, inputStatus in
115 // コンバータにデータが利用可能であることを通知
116 inputStatus.pointee = .haveData
117 // CMSampleBufferをAVAudioPCMBufferに変換して返す(入力値を渡す)
118 return convertSampleBufferToPCMBuffer(sampleBuffer: sampleBuffer)
119 }
120
121 if outputStatus == .error {
122 // 変換エラーが発生した場合、エラーを投げる
123 if let error = error {
124 throw error
125 }
126 }
127
128 // AVAudioPCMBufferをDataに変換して返す
129 return try convertAudioBufferToData(audioBuffer: outBuffer)
130 }
131}録音データの Data 型を Float 配列に変換
Whisperに渡す録音データは -1 ~ 1 のFloat配列にする必要があります。
そのため、録音データのData型をFloat配列に変換する処理を実装します。
Data型をExtensionで拡張してFloat配列に変換するメソッドを実装しました。
参考:https://github.com/ggml-org/whisper.cpp/blob/master/examples/whisper.swiftui/whisper.swiftui.demo/Utils/RiffWaveUtils.swift
1// DataExtension.swift
2
3import Foundation
4
5extension Data {
6 func toNormalizedFloats() -> [Float] {
7 let elementCount = count / 2 // 16bitの音声前提のため2で割る
8 guard elementCount > 0 else { return [] }
9
10 // -1 ~ 1 までのFloat配列に変換
11 return self.withUnsafeBytes { ptr in
12 let int16Ptr = ptr.bindMemory(to: Int16.self)
13 var floats = [Float](repeating: 0.0, count: elementCount)
14 for i in 0..<elementCount {
15 let short = Int16(littleEndian: int16Ptr[i])
16 let normalizedFloat = Float(short) / 32767.0
17 floats[i] = Swift.max(-1.0, Swift.min(normalizedFloat, 1.0))
18 }
19 return floats
20 }
21 }
22}Whisperの呼び出し処理を実装
Whisper の文字起こし処理を呼び出すメソッドを実装します。
インスタンス作成時にWhisperのモデルファイルを読み込んでWhisperを初期化します。
transcribeメソッドを定義して、音声データをFloat配列に変換後、fullTranscribe(sample:) で文字起こししたテキストを返します。
1// WhisperTranscriptionService.swift
2
3import Foundation
4
5final class WhisperTranscriptionService: NSObject {
6 // Audio format (16kHz / 16-bit / mono)
7 static let sampleRate: Double = 16_000
8 static let bytesPerSample: Double = 2
9 static var bytesPerSecond: Double { sampleRate * bytesPerSample }
10
11 private var whisperContext: WhisperContext?
12
13 private var builtInModelUrl: URL? {
14 Bundle.main.url(forResource: "ggml-small-q8_0", withExtension: "bin")
15 }
16
17 override init() {
18 super.init()
19 loadModel()
20 }
21
22 private func loadModel(path: URL? = nil, log: Bool = true) {
23 do {
24 whisperContext = nil
25 let modelUrl = path ?? builtInModelUrl
26 if let modelUrl {
27 whisperContext = try WhisperContext.createContext(path: modelUrl.path())
28 }
29 } catch {
30 print("Model load error:", error.localizedDescription)
31 }
32 }
33
34 func transcribe(_ data: Data) async -> String? {
35 guard let whisperContext else { return nil }
36
37 // Data(PCM16) → [Float] に変換
38 let samples = data.toNormalizedFloats()
39
40 await whisperContext.fullTranscribe(samples: samples)
41 let text = await whisperContext.getTranscription()
42 return text
43 }
44}音声データを変換して状態を更新
音声データを文字起こしする処理を実装します。
文字起こしはある程度の長さの音声を処理する必要があるため、今回は6秒ごとに文字起こしをするよう実装しました。
そして、文字起こししたテキストで状態を更新し、UIへ反映させます。
1// TranscriptionRecorder.swift
2
3import AVFoundation
4import ReplayKit
5
6@MainActor
7final class TranscriptionRecorder: ObservableObject {
8 @Published var isRecording = false
9 @Published var transcriptText = "文字起こしを開始するには「Start」ボタンを押してください。"
10
11 // 以下の4行を追加
12 private var sampleBufferConverter: CMSampleBufferConverter?
13 private let whisperTranscriptionService = WhisperTranscriptionService()
14 private var audioData = Data()
15 let chunkDurationBytes = 6 * Int(WhisperTranscriptionService.bytesPerSecond) // 6秒あたりのバイト数
16
17 func start() {
18 guard !isRecording else { return }
19 isRecording = true
20 transcriptText = ""
21 // 以下の2行を追加
22 audioData = Data()
23 sampleBufferConverter = nil
24
25 RPScreenRecorder.shared().isMicrophoneEnabled = true
26 RPScreenRecorder.shared().startCapture(handler: { [weak self] sampleBuffer, sampleBufferType, error in
27 guard let self = self else { return }
28
29 if let error {
30 print("Capture handler error:", error)
31 return
32 }
33
34 // マイク音声のみ処理
35 if sampleBufferType == .audioMic {
36 // 以下の文字起こし処理を追加
37 // コンバータの初期化(最初のバッファのみで実行)
38 if sampleBufferConverter == nil {
39 do {
40 sampleBufferConverter = try CMSampleBufferConverter(sampleBuffer: sampleBuffer, outSampleRate: WhisperTranscriptionService.sampleRate)
41 return
42 } catch {
43 print("CMSampleBufferConverter initialization failed:", error)
44 // 初期化失敗時は以降の処理を停止
45 stop()
46 return
47 }
48 }
49
50 do {
51 guard let sampleBufferConverter = sampleBufferConverter else { return }
52 let data = try sampleBufferConverter.resampleAndConvert(sampleBuffer: sampleBuffer)
53 audioData.append(data)
54
55 // 音声データを6秒ごとに文字起こし
56 if audioData.count >= chunkDurationBytes {
57 let chunk = audioData.prefix(chunkDurationBytes)
58 audioData.removeFirst(chunk.count)
59
60 Task {
61 if let text = await self.whisperTranscriptionService.transcribe(chunk) {
62 self.transcriptText += text
63 }
64 }
65 }
66 } catch {
67 print("Audio processing error:", error)
68 }
69
70 }
71 }, completionHandler: { [weak self] error in
72 guard let self = self else { return }
73
74 if let error {
75 print("completionHandler error:", error)
76 Task { @MainActor in
77 self.isRecording = false // エラー終了時もフラグをリセット
78 }
79 return
80 }
81 })
82 }
83
84 func stop() {
85 guard isRecording else { return }
86
87 RPScreenRecorder.shared().stopCapture { [weak self] error in
88 if let error { print("stop error:", error) }
89 Task { @MainActor in
90 guard let self = self else { return }
91
92 // 以下の端数データ処理を追加
93 // 録音終了時にバッファに残った端数データを処理
94 if !self.audioData.isEmpty {
95 let chunk = self.audioData.prefix(self.audioData.count)
96 self.audioData.removeFirst(chunk.count)
97
98 if let text = await self.whisperTranscriptionService.transcribe(chunk) {
99 self.transcriptText += text
100 }
101 }
102
103 self.isRecording = false
104 self.sampleBufferConverter = nil // 追加
105 }
106 }
107 }
108}Whisperの日本語設定
Whisper で日本語で文字起こしする場合は追加で設定が必要になります。
Whisper の fullTranscribe の設定が en になっているため、日本語にするためには ja に変更します。
1// LibWhisper.swift
2
3...
4
5actor WhisperContext {
6
7...
8
9 func fullTranscribe(samples: [Float]) {
10 // Leave 2 processors free (i.e. the high-efficiency cores).
11 let maxThreads = max(1, min(8, cpuCount() - 2))
12 print("Selecting \(maxThreads) threads")
13 var params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY)
14 "ja".withCString { ja in
15 // Adapted from whisper.objc
16 params.print_realtime = true
17 params.print_progress = false
18 params.print_timestamps = true
19 params.print_special = false
20 params.translate = false
21 params.language = ja
22 params.n_threads = Int32(maxThreads)
23 params.offset_ms = 0
24 params.no_context = true
25 params.single_segment = false
26
27 whisper_reset_timings(context)
28 print("About to run whisper_full")
29 samples.withUnsafeBufferPointer { samples in
30 if (whisper_full(context, params, samples.baseAddress, Int32(samples.count)) != 0) {
31 print("Failed to run the model")
32 } else {
33 whisper_print_timings(context)
34 }
35 }
36 }
37 }
38
39...以上の実装で、録音した音声を文字起こしして画面に表示します。
おわりに
今回、Whisperを使った文字起こしアプリを作成してみました。
今回のggml-small-q8_0モデルでは、少し間違う事もありますが、かなりの精度で文字起こしが可能だったので驚きました。
上位のモデルもありますが、すぐにスマホが熱くなってしまうため、快適に長時間使えるように、どのモデルが最適なのか、引き続き検証していく必要がありそうです。
また、Whisperで文字起こしした際に自動で「(音楽)」や「ご視聴ありがとうございました」といったテキストが付いてしまう問題があるため、取り除く方法についても今後調査していきたいです。
この記事が Whisper を導入される方に少しでも参考になれば幸いです。
最後までお読みいただき、ありがとうございました!
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!カジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ

業務ではiOS開発に携わらせていただいています。 まだまだ分からないことだらけで、日々分からないことと戦いながら仕事をしている者です。 ブログ記事は暖かい目で見ていただけるとありがたいです。




