• トップ
  • ブログ一覧
  • 【iOS】Whisperで文字起こししてみた
  • 【iOS】Whisperで文字起こししてみた

    はじめに

    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 を導入される方に少しでも参考になれば幸いです。
    最後までお読みいただき、ありがとうございました!

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!カジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    いまむー(エンジニア)
    いまむー(エンジニア)
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background