その他

ローカルLLMで音声文字起こしアプリを作る【Swift編①】環境構築から基本アプリ化まで

その他
この記事は約32分で読めます。

はじめに

みなさん、こんにちは。株式会社インプルのyotaです!

前回のPython編に続き、今回は音声入力LLMの「Swift編」をスタートします!

本記事では、Macようのネイティブアプリとして音声文字起こし + LLM校正パイプライン構築にするための、環境構築から基本的なアプリ化までの手順を詳しく解説していきます。

前回の記事(Python編)もぜひ読んでみてください!!

環境構築とツールの確認

まずはMacでの開発に必要な各種ツールのインストール状況を確認します。なお、ローカルLLMを動作させるための「Ollama」は、すでに導入されている前提で進めます。

1. Xcodeのインストール確認

ターミナルを起動し(初期ディレクトリにて)、以下のコマンドを実行してXcodeのバージョンを確認します。

xcode-select --version
xcodebuild -version

もしXcodeがインストールされていない場合は、以下のようなエラーがターミナルに出力されます。
xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance
この表示が出た場合は、App StoreからXcodeをインストールしてください(容量が10GB以上ありますのでご注意ください)。

2. Xcode Command Line Toolsのインストール

次に、以下のコマンドをターミナル(初期ディレクトリにて)で実行し、開発用ツール群をインストールします。

xcode-select --install

すでに入っている場合はalready installedと表示されます。Macユーザーなら基本的には最初から入っているはずです。

3. Swiftのバージョン確認

続いて、こちらもデフォルトで入っているであろうSwiftのバージョンを確認していきます。以下のコマンドをターミナル(初期ディレクトリにて)で実行します。

swift --version

次のような結果が表示されればOKです。
swift-driver version: 1.148.6 Apple Swift version 6.3.1 (swiftlang-6.3.1.1.2 clang-2100.0.123.102)<br>Target: arm64-apple-macosx26.0

4. Xcode初回起動後のコマンドライン設定

Xcodeのインストール完了後、初めて起動する際に追加コンポーネントのインストールを求められるため、必ず初回起動を済ませてください。
完了後、ターミナルで以下のコマンドを実行します。

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

Macには標準でgitswiftといった開発用コマンド(Command Line Tools)を呼び出す仕組みがありますが、この設定を行うことで、「/Applications/Xcode.app」に内包されている最新のツール群を直接参照するようになります。

※なお、XcodeのGUIメニューからSettings... > Locationsを開き、Command Line Tools:の項目を確認した際、ドロップダウンが空欄になっている場合はインストールしたXcodeのバージョンを手動で選択することでも全く同じ設定が可能です。上記のコマンドは、このGUI操作をターミナルで強制的に実行したものとなります。

この設定を行うことで、ターミナルでgitなどの開発コマンドを実行したときにxcrun: error: active developer path … does not existといった参照エラーが発生するのを防ぐメリットがあります。さらに、今後Homebrewなどをターミナルから利用する際にも、ツール側が「Xcodeがどこにあるか」を迷わず見つけられるようになるため、外部ツールとの連携が非常にスムーズになります。

設定が終わったら、確認のためにターミナル(初期ディレクトリにて)で以下を実行します。

xcodebuild -version

以下のように表示されれば無事に完了です。

Xcode xx.x
Build version xxxxxxx

Xcodeプロジェクトの新規作成

開発環境が整ったら、Xcodeを使って新しくプロジェクトを作成します。

  1. Xcodeを起動します。
  2. 「Create New Project」を選択します。
  3. プラットフォームのタブから「macOS」を選択します。
  4. テンプレートの中から「App」を選択して「Next」をクリックします。

各設定項目は以下の内容で入力します。

  • Project Name:VoiceTranscriber
  • Team:なし
  • Organization Identifier:com.yourname(任意の識別子)
  • Bundle Identifier:自動入力されます
  • Interface:SwiftUI
  • Language:Swift
  • Storage:None

保存先を指定して「Create」をクリックすると、プロジェクトが自動生成されます。左側のファイルツリーに、以下のような構成が見えるようになります。

VoiceTranscriber/
├── VoiceTranscriberApp.swift
├── ContentView.swift
└── Assets.xcassets

WhisperKitの追加と権限設定

今回はローカルでの音声文字起こしを行うため、Apple Siliconに最適化された音声認識ライブラリ「WhisperKit」をパッケージとして導入します。

1. パッケージの追加(Xcode上の操作)

  1. Xcodeの上部メニューからFile -> Add Package Dependenciesを選択します。
  2. 右上の検索窓に以下のリポジトリURLを入力します。
    https://github.com/argmaxinc/WhisperKit
  3. 「Add Package」をクリックします。
  4. WhisperKitのターゲットとしてVoiceTranscriberを選択し、再度「Add Package」をクリックします。

2. マイクと音声認識の権限追加(Infoタブ)

Macのマイクや音声認識機能にアクセスするための記述を追加します。

  1. 左側のファイルツリーから、一番上にある青いアイコンのVoiceTranscriberフォルダを選択します。
  2. 中央ペインに表示されるプロジェクト設定からInfoタブを選択します。
  3. Custom macOS Application Target Properties+ボタンをクリックします。
  4. 以下の2つのKeyとValueのペアを追加してください。
    Key1:Privacy - Microphone Usage Description
    Value1:音声入力のためにマイクを使用します
    Key2:Privacy - Speech Recognition Usage Description
    Value2:音声をテキストに変換するために使用します

3. App Sandboxの権限解放

macOSアプリ特有のセキュリティ制限(Sandbox)を解除し、マイク入力とローカルLLM(Ollama)への通信を許可します。

  1. 同じくプロジェクト設定画面からSigning & Capabilities タブを開きます。
  2. App Sandboxの項目内にある以下の2箇所にチェックを入れます。
    ・Network:Outgoing Connections (Client)(LLMとの通信用)
    ・Hardware:Audio Input(マイク入力用)

アプリの実装

アプリは以下の4つのファイルで構成されます。既存のファイルを編集、または新しくファイルを作成してコードを書き込みます。

VoiceTranscriber/
├── VoiceTranscriberApp.swift   # エントリポイント(既存)
├── ContentView.swift           # メインUI
├── TranscriberViewModel.swift  # 録音・STT・LLM処理
└── OllamaClient.swift          # Ollama REST APIクライアント

VoiceTranscriberApp.swift(エントリポイント)

アプリの起動口です。自動生成されたコードを少しだけ修正し、ウィンドウのデフォルトサイズ(760×540)や最小サイズを指定して、UIが崩れないようにしています。

import SwiftUI

@main
struct VoiceTranscriberApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .windowResizability(.contentMinSize)
        .defaultSize(width: 760, height: 540)
    }
}

ContentView.swift(メインUI)

画面の見た目(View)を定義するファイルです。上部にステータス、中央に「生テキスト」と「LLM補正後テキスト」を並べる2カラムのテキストエリア、下部に録音開始/停止ボタンや入力音量を視覚的に示すメーター(GeometryReaderと色分けで実装)を配置しています。すべての状態管理は後述のViewModel(vm)に任せています。

import SwiftUI

struct ContentView: View {
    @StateObject private var vm = TranscriberViewModel()

    var body: some View {
        VStack(spacing: 0) {

            // --- ヘッダー ---
            HStack {
                Text("🎤 Voice Transcriber")
                    .font(.title2).bold()
                Spacer()
                Text(vm.statusMessage)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            .padding(.horizontal, 20)
            .padding(.vertical, 14)
            .background(Color(NSColor.windowBackgroundColor))

            Divider()

            // --- テキストエリア ---
            HStack(spacing: 12) {
                VStack(alignment: .leading, spacing: 6) {
                    Text("音声認識(生テキスト)")
                        .font(.subheadline).bold()
                    ScrollView {
                        Text(vm.rawText.isEmpty ? "ここに生テキストが表示されます" : vm.rawText)
                            .foregroundColor(vm.rawText.isEmpty ? .secondary : .primary)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(8)
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(NSColor.textBackgroundColor))
                    .cornerRadius(8)
                }

                VStack(alignment: .leading, spacing: 6) {
                    Text("LLM補正後テキスト")
                        .font(.subheadline).bold()
                    ScrollView {
                        Text(vm.correctedText.isEmpty ? "ここに補正後テキストが表示されます" : vm.correctedText)
                            .foregroundColor(vm.correctedText.isEmpty ? .secondary : .primary)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(8)
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(NSColor.textBackgroundColor))
                    .cornerRadius(8)
                }
            }
            .padding(16)

            Divider()

            // --- フッター ---
            HStack(spacing: 12) {

                // 音量メーター
                VStack(alignment: .leading, spacing: 2) {
                    Text("入力音量")
                        .font(.caption)
                        .foregroundColor(.secondary)
                    GeometryReader { geo in
                        ZStack(alignment: .leading) {
                            RoundedRectangle(cornerRadius: 4)
                                .fill(Color.secondary.opacity(0.2))
                            RoundedRectangle(cornerRadius: 4)
                                .fill(vm.volumeLevel < 0.03 ? Color.gray :
                                      vm.volumeLevel < 0.4  ? Color.blue : Color.green)
                                .frame(width: geo.size.width * CGFloat(vm.volumeLevel))
                        }
                    }
                    .frame(height: 14)
                }
                .frame(maxWidth: .infinity)

                // クリアボタン
                Button("クリア") { vm.clearAll() }
                    .buttonStyle(.bordered)
                    .disabled(vm.isProcessing)

                // 録音トグルボタン
                Button {
                    vm.isRecording ? vm.stopRecording() : vm.startRecording()
                } label: {
                    Label(
                        vm.isRecording ? "録音停止" : (vm.isProcessing ? "処理中..." : "録音開始"),
                        systemImage: vm.isRecording ? "stop.circle.fill" : "mic.circle.fill"
                    )
                    .frame(width: 130)
                }
                .buttonStyle(.borderedProminent)
                .tint(vm.isRecording ? .red : .blue)
                .disabled(vm.isProcessing)
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 14)
            .background(Color(NSColor.windowBackgroundColor))
        }
        .frame(minWidth: 720, minHeight: 500)
        .task { await vm.setup() }
    }
}

#Preview {
    ContentView()
}

TranscriberViewModel.swift(中核ロジック)

アプリの頭脳となる部分です。以下の処理を一手に担います。

  • マイク入力(startRecording):
    AVAudioEngineを使って音声をバッファに溜め込みつつ、音量メーター用のRMS(二乗平均平方根)を計算します。
  • 文字起こし(transcribeAndCorrect):
    録音停止後、取得した音声(48kHz等)をWhisperKitが求める16kHzにリサンプリングして文字起こしを実行します。
  • LLM連携:
    WhisperKitが出力した「生テキスト」を後述のOllamaClientに渡して補正をかけます。
import Foundation
import Combine
import AVFoundation
import AVFAudio
import WhisperKit

@MainActor
class TranscriberViewModel: ObservableObject {

    @Published var rawText       = ""
    @Published var correctedText = ""
    @Published var statusMessage = "初期化中..."
    @Published var isRecording   = false
    @Published var isProcessing  = false
    @Published var volumeLevel: Float = 0.0

    private var whisper: WhisperKit?
    private var audioEngine  = AVAudioEngine()
    private var audioBuffers: [AVAudioPCMBuffer] = []

    func setup() async {
        statusMessage = "WhisperKitモデルをロード中..."
        do {
            let recommended = await WhisperKit.recommendedRemoteModels()
            whisper = try await WhisperKit(model: recommended.default)
            statusMessage = "準備完了 ✅"
        } catch {
            statusMessage = "初期化エラー: \(error.localizedDescription)"
        }
    }

    func startRecording() {
        guard !isRecording else { return }
        AVAudioApplication.requestRecordPermission { granted in
            Task { @MainActor in
                guard granted else {
                    self.statusMessage = "マイクへのアクセスが拒否されました。システム設定 → プライバシー → マイクで許可してください。"
                    return
                }
                self._startRecordingInternal()
            }
        }
    }

    func _startRecordingInternal() {
        audioBuffers.removeAll()
        isRecording = true
        statusMessage = "録音中... 話してください"

        let inputNode  = audioEngine.inputNode
        let nativeFormat = inputNode.inputFormat(forBus: 0)

        inputNode.installTap(onBus: 0, bufferSize: 4096, format: nativeFormat) { [weak self] buffer, _ in
            guard let self else { return }
            self.audioBuffers.append(buffer)

            if let channelData = buffer.floatChannelData?[0] {
                let frameLength = Int(buffer.frameLength)
                var rms: Float = 0
                for i in 0..<frameLength { rms += channelData[i] * channelData[i] }
                rms = sqrt(rms / Float(frameLength))
                Task { @MainActor in
                    self.volumeLevel = min(rms / 0.1, 1.0)
                }
            }
        }

        do {
            try audioEngine.start()
        } catch {
            statusMessage = "録音開始エラー: \(error.localizedDescription)"
            isRecording = false
        }
    }

    func stopRecording() {
        guard isRecording else { return }
        audioEngine.inputNode.removeTap(onBus: 0)
        audioEngine.stop()
        isRecording  = false
        isProcessing = true
        volumeLevel  = 0.0
        statusMessage = "文字起こし中..."
        Task { await transcribeAndCorrect() }
    }

    private func transcribeAndCorrect() async {
        defer { isProcessing = false }

        guard let whisper, !audioBuffers.isEmpty else {
            statusMessage = "音声が検出されませんでした"
            return
        }

        var samples: [Float] = []
        let targetSampleRate: Double = 16000

        for buffer in audioBuffers {
            let frameLength = Int(buffer.frameLength)
            guard frameLength > 0 else { continue }
            let sourceSampleRate = buffer.format.sampleRate

            var sourceData = [Float](repeating: 0, count: frameLength)
            if let channelData = buffer.floatChannelData {
                sourceData = Array(UnsafeBufferPointer(start: channelData[0], count: frameLength))
            } else if let int16Data = buffer.int16ChannelData {
                for i in 0..<frameLength {
                    sourceData[i] = Float(int16Data[0][i]) / 32768.0
                }
            }

            let ratio = targetSampleRate / sourceSampleRate
            let outputLength = Int(Double(frameLength) * ratio)
            var resampled = [Float](repeating: 0, count: outputLength)
            for i in 0..<outputLength {
                let srcIndex = Double(i) / ratio
                let srcIdx   = Int(srcIndex)
                let frac     = Float(srcIndex - Double(srcIdx))
                let s0 = srcIdx < frameLength     ? sourceData[srcIdx]     : 0
                let s1 = (srcIdx + 1) < frameLength ? sourceData[srcIdx + 1] : 0
                resampled[i] = s0 + frac * (s1 - s0)
            }
            samples.append(contentsOf: resampled)
        }

        // 無音チェック
        let rms = sqrt(samples.map { $0 * $0 }.reduce(0, +) / Float(samples.count))
        guard rms > 0.001 else {
            statusMessage = "音声が検出されませんでした"
            return
        }

        do {
            let results = try await whisper.transcribe(
                audioArray: samples,
                decodeOptions: DecodingOptions(
                    task: .transcribe,
                    language: "ja",
                    suppressBlank: true,
                    noSpeechThreshold: 0.6
                )
            )
            let raw = results.map(\.text).joined().trimmingCharacters(in: .whitespacesAndNewlines)

            guard !raw.isEmpty else {
                statusMessage = "認識結果が得られませんでした"
                return
            }

            rawText += (rawText.isEmpty ? "" : "\n") + raw
            statusMessage = "LLMで補正中..."

            let corrected = try await OllamaClient.shared.correct(text: raw)
            correctedText += (correctedText.isEmpty ? "" : "\n") + corrected
            statusMessage = "準備完了 ✅"

        } catch {
            statusMessage = "エラー: \(error.localizedDescription)"
        }
    }

    func clearAll() {
        rawText       = ""
        correctedText = ""
        statusMessage = "準備完了 ✅"
    }
}

OllamaClient.swift(ローカルLLM通信用)

ローカルで起動しているOllama(今回はqwen3:8bモデル)のREST APIへHTTPリクエストを送るクライアントです。

import Foundation

struct OllamaClient {
    let host: String
    let model: String

    static let shared = OllamaClient(
        host: "http://127.0.0.1:11434",
        model: "qwen3:8b"
    )

    private let systemPrompt = """
    あなたは音声文字起こしの校正専門家です。
    入力は音声認識エンジンが出力した生テキストです。以下のルールに従って補正してください。

    【必須処理】
    1. ひらがなで書かれた語は適切な漢字・カタカナに変換する
    2. 音声認識の誤変換を文脈から推測して修正する
    3. 文末には必ず句点(。)を付ける。文中の区切りには読点(、)を挿入する。
    4. 数字は適切な表記に変換する(例:さんじ→3時)

    【禁止事項】
    - 文体・敬語レベルの変更
    - 内容の追加・削除・要約
    - 説明やコメントの付加
    - 言語の変換・翻訳(入力言語のまま出力すること)

    【出力形式】
    補正後のテキストのみを出力する。それ以外は何も出力しない。
    """

    func correct(text: String) async throws -> String {
        guard !text.trimmingCharacters(in: .whitespaces).isEmpty else { return "" }

        let url = URL(string: "\(host)/api/chat")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: Any] = [
            "model": model,
            "stream": false,
            "options": ["temperature": 0.1],
            "think": false,
            "messages": [
                ["role": "system", "content": systemPrompt],
                ["role": "user",   "content": text]
            ]
        ]
        request.httpBody = try JSONSerialization.data(withJSONObject: body)

        let (data, _) = try await URLSession.shared.data(for: request)
        let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
        let message = json?["message"] as? [String: Any]
        return (message?["content"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
    }
}

起動テストと発生した問題

実装が完了したらいよいよアプリをビルドして実行してみます

短い文章であれば、上の動画のように、非常にスムーズかつ正確に文字起こしとLLM補正が行われます。しかし話す文章が長くなってくると、奇妙な問題が発生しました。

音声認識(生テキスト)
我輩は猫である名前はまだないどこで生まれたかとんと見当がつかぬ何でも薄暗いジメジメしたところでニャーニャー鳴いていたことだけは記憶している我輩はここで初めて人間というものを見たしかも後で聞くとそれは諸星という人間中で一番同悪な種族であったそうだこの諸星というのは時々我々を捕まえて似て食うという話であるありがとうございました

上記の画像のように、文章が長すぎるとなぜかテキストの最後に「ありがとうございました」と言う謎のハルシネーションをLLMが出力してしまいます。何が「ありがとうございました」なのか…

XcodeからGithubへの連携手順

ここからは、開発したコードをGithubのリポジトリへコミット・プッシュして管理する手順に進みます。まずはXcodeにGithubアカウントを紐づけます。

  1. XcodeメニューからSettings -> Source Control -> Accountsを開きます。
  2. Add Account... -> Githubを選択します。
  3. 自身の「ユーザー名」と「Token」を入力します。
    ※ここでのTokenとは、通常のログインパスワードではなく、Github上で発行するPersonal Access Token(PAT)を指します。

Github側でのToken(PAT)発行手順

  1. ブラウザでhttps://github.com/settings/tokensにアクセスします。
  2. Generate new token → Generate new token (classic)をクリックします。
  3. 各項目を以下のように設定します:
    ・Note:Xcode など任意の名前
    ・Expiration:任意(90daysなど)
    ・Scopes:repo にチェックを入れますd(これだけでOKです)
  4. ページ最下部の「Generate Token」をクリックします。
  5. 画面に表示されたトークン(ghp_から始まる文字列)をコピーします。
    ⚠️注意:このトークンは画面を一度閉じると二度と表示されません。必ずその場でコピーして控えてください。

コピーしたトークン文字列を、先ほどのXcodeのToken:入力欄に貼り付けることで、無事にサインインが完了します。

Gitでのコミットとリモートへのプッシュ

アカウント連携を済ませ、いざXcodeから新規にコミットをしようと上部メニューのIntegrate -> New Git Repository…を選択したところ、以下のようなダイアログが出力されました。

All projects are already under source control.
There is no need to create any additional repositories as all of the workspace is already under source control.

どうやら、Xcodeで最初にプロジェクトを立ち上げた(Createした)タイミングで、自動的にローカルのGitリポジトリが作成され、最初のコミット(Initial Commit)まで完了していたようです。

実際にGithub側で確認してみると、反映されたタイミングが「2時間前」になっていました。しかし、自動で行われたコミットであるため、今回私たちが記述したファイル群や追加パッケージの変更はまだ含まれていません。

そこで、よくわからなくなったため、確実性を期して一度ターミナルから最初のリモートプッシュを行いました…(XcodeのUIによるコミット等は次の記事で行います)

git remote add origin https://github.com/あなたのユーザー名/VoiceTranscriber.git
git branch -M main
git push -u origin main

git add VoiceTranscriber.xcodeproj/project.pbxproj \
        VoiceTranscriber/ContentView.swift \
        VoiceTranscriber/OllamaClient.swift \
        VoiceTranscriber/TranscriberViewModel.swift \
        VoiceTranscriber/VoiceTranscriberApp.swift

git commit -m "feat: implement WhisperKit STT + Ollama LLM pipeline with volume meter"

git push origin main

※なお、xcuserdata/フォルダやxcshareddata/フォルダは、Xcodeが個人の作業状態を記録するための設定ファイルであるため、Gitの管理(コミット対象)には含めないように注意してください。

Xcode標準のGit管理UIについて

「VSCodeのようなGit管理UIはないのか?」とXcode内を探してみたところ、左サイドバーにあるアイコン群の中からSource Control Navigatorを発見しました。次回以降はこのXcodeのGUIを用いたGit管理に挑戦します!

まとめ

今回はXcodeでの開発環境の構築から、WhisperKitとOllamaを組み合わせた基本的な音声文字起こしアプリの実装、そしてGithubでのソースコード管理までを完了しました。

起動テストでは、短い音声であればスムーズに動作したものの、文章が長くなるとLLMのハルシネーションによって、末尾に「ありがとうございました」という謎の文言が追加される課題が見つかりました。

次回は、この録音終了後に一括処理するバッチ方式を改良し、Speech Analyzerを用いたリアルタム文字起こし機能へのアップデートに挑戦します。