🤖

"AIと英会話学習の融合!ChatGPTと深層学習を活用したアプリ開発"

2023/04/28に公開

はじめに

オンライン英会話が手軽に学べる現代、私たちの言語学習の方法はChatGPTの登場でさらなる可能性が広がりました。本記事では、実際にオンライン英会話を続けていたエンジニアである私が、英会話学習アプリを自作し、独自の学習方法を確立した方法について紹介します。

全ての学習者にとって最適な方法ではないかもしれませんが、個別のニーズに応じて英会話アプリを自作し、独自の学習方法を開拓することができます。言語学習をマスターしたい方やAI技術に興味を持つ方に届けば幸いです。

Githubにはすぐに試せるプロジェクトも用意しています。アプリを自由にカスタマイズしてみてください。詳細な実装はリポジトリを参照し、本記事では重要なポイントを解説します。
https://github.com/rinov/SpeakEasy/

アプリの概要

このアプリは、英会話のためのiOSアプリで、主に機能としては以下があります。

  • ユーザーの音声入力をリアルタイムに、オンデバイス処理する音声認識・入力機能
  • 深層学習を利用した自然な発音によるAIの音声出力

上記を技術的な要素に分解すると、以下のような仕組みになっています。

  1. 音声入力 - SFSpeechRecognizerを利用して、ユーザーが話す英語の音声をリアルタイムでテキストに変換します。
  2. 会話のレスポンス生成 - OpenAIのChatGPTを使用して、ユーザーの発言に対する会話の回答を生成します。
  3. 音声出力 - AWS Pollyを利用して、アプリが生成した英語のテキストを自然な音声に変換し、ユーザーにフィードバックします。

また、アプリでは、MVVM(Model-View-ViewModel)とSwiftUIを採用しています。
実験的な興味で作成したこともあり、コードはそこまで綺麗では無くUnitTestも書いていませんためご了承ください。

Whisper APIは使わないの?

OpenAI社の音声認識サービスWhisper APIを利用することでもちろん、より精度を高くすることは良い選択肢であることは変わりません。

しかし、試してみるとわかりますが音声入力の識別からAPIでやる場合最終的にAIが音声回答するまでに数秒かかってしまい、会話の精度よりもテンポが悪くなってしまいます。

以下でも掲示されているように日本語は他の言語に比べ、会話中のレスポンスがとても早い言語です。これは日本人が会話の応答速度に対して、他言語よりも敏感であるという客観的な事実でもあるため、学習中にそこに違和感をなるべく感じないように、今回はとにかく応答速度が速く、自然に会話ができることにフォーカスしています。
https://twitter.com/gyakuse/status/1640748179976249350

月額いくらなの?

このアプリを利用して週3回の15分の英会話をプランにした場合の月額は、およそ400~500円です。

以下は試算過程です。
普段の会話では一方が長文をずっと話すことはありません、そのためトークンを20(およそ20語)としてデフォルト設定しているため、1トークンあたり平均5文字で構成されているとすると会話1つの内容は100文字程度ということになります。

これにより会話の往復では200文字となり、15秒で1往復が完了すると仮定すると15分で60回のやりとりが行わわれ、60×200=12000文字となり、週に3回だと月間144,000文字となります。これをAWS Pollyの音声出力のみで月額換算にするとおよそ$2.3になります。
https://aws.amazon.com/jp/polly/pricing/?nc=sn&loc=4

続いてChatGPTの利用料金については月間の文字数をトークンに変換するとおよそ28,800Tokensとなるため月額で$0.0576になります。

gpt-3.5-turbo $0.002 / 1K tokens
https://openai.com/pricing

合計コストは月間2.3576となり日本円に換算(1=133円)するとおよそ313円になります。これに会話以外の会話履歴のコンテキストやシステム指示の文字も含まれるとすると、もう少しだけ高い値段になります。

※AWS Pollyはかなりの無料枠があるため最初は費用が発生しないです
※OpenAIは支払い設定でハードリミットの額を設定できるので、これを超えた場合はAPIがrejectされるようになり安全に運用できます

開発環境の準備

  • XCode 14.2
  • CocoaPods
  • AWS認証情報
    AWSにログインし、API情報からAccessKeyとSecretKeyを発行します。
    https://aws.amazon.com/jp/?nc2=h_lg
  • OpenAI APIキー
    以下のURLから"Get Started"をクリックしアカウント作成後に、支払い設定をしAPIキーの発行をします。
    https://openai.com/product

SFSpeechRecognizerを使った音声入力の実装

このセクションでは、音声入力の処理方法に焦点を当て、SFSpeechRecognizerを用いた実装方法を解説します。
SFSpeechRecognizerは音声入力、音声認識をするサービスで任意の言語の音声からテキスト変換、テキストからの言語判定が可能です。

また、この学習モデルはネットワークを介すことでより精度あげることもできますが、オンデバイスで高速なリアルタイム音声入力をすることが可能です。

以下にアプリで実装している音声認識ロジックを記載します。

要点として2つあります。
1つは、本アプリでは会話中は、いかなる操作も不要です。(たとえば送信ボタンや入力開始ボタンなどを押さなければならないなど)
それを実現するために waitsForResponseSecを用意しています、これは音声の入力がなくなった地点から何秒後を会話の入力が終わったとみなすかの閾値です。
これを短くすればするほど会話の応答性が上がり、長くするほどAIは会話の続きを待ってくれます。
これは現実世界でもそうだと思いますが、会話が終わったかどうかの基準や区切りはトレードオフになると思いますので、適宜調整をしてください。

2つめは、requiresOnDeviceRecognitionの設定です。これを有効にするとネットワークの介入なしにリアルタイムでデバイス上で音声認識ができるようになります。この設定をすることで他のサービスなどよりも素早い応答性を実現しています。
ですが、より入力の精度を高めたく十分なネットワーク速度がある場合は無効にすることもニーズによってはあり得ます。

import Foundation
import Speech
import Combine
...
@MainActor
class SpeechRecognizerViewModel: ObservableObject {
    @Published var state: AppState = .notRecording
    @Published var userText: String = ""
    @Published var assistantText: String = ""
    @Published var isInputFinished = false

    var subscriptions = Set<AnyCancellable>()

    // ユーザーの音声入力が止まってから何秒後で入力が終わったとみなすか
    private let waitsForResponseSec = 2.0
    private let audioSession = AVAudioSession.sharedInstance()
    private let audioEngine = AVAudioEngine()

    private var silenceTimer: Timer?

    private var speechRecognizer: SFSpeechRecognizer {
        // NOTE: Localeによって音声入力の言語を変更可能 e.g. ja-JP
        let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
        guard let recognizer = recognizer else { fatalError("Failed to prepare recognizer")}
        recognizer.supportsOnDeviceRecognition = true
        return recognizer
    }

    func requestAuthorization() {
        SFSpeechRecognizer.requestAuthorization { [weak self] _ in
            try? self?.record()
        }
    }
    ...

    func stop() {
        guard SFSpeechRecognizer.authorizationStatus() == .authorized else { return }
        
        if case .recording(let node,let task) = self.state {
            self.audioEngine.stop()
            node.removeTap(onBus: 0)
            task.finish()
            self.state = .notRecording
        }
    }
    
    func record() throws {
        guard state == .notRecording, SFSpeechRecognizer.authorizationStatus() == .authorized else { return }

        let recognitionRequest = SFSpeechAudioBufferRecognitionRequest()

        // 音声入力内容をリアルタイムに部分認識していくようにする
        recognitionRequest.shouldReportPartialResults = true

        // 音声認識の速度を向上するためにオンデバイス処理を有効にする(※falseにすることによって精度は向上する)
        recognitionRequest.requiresOnDeviceRecognition = true

        try self.audioSession.setCategory(.record, mode: .default, options: .duckOthers)
        try self.audioSession.setActive(true, options: .notifyOthersOnDeactivation)

        let recognitionTask = self.speechRecognizer.recognitionTask(with: recognitionRequest, resultHandler: { (result, error) in
            if let result = result?.bestTranscription.formattedString, !result.isEmpty {
                self.userText = result
                self.handleSilenceTimer()
            }
        })

        let inputNode = self.audioEngine.inputNode
        let recordingFormat = inputNode.outputFormat(forBus: 0)

        // 音声の入力があるたびにどれくらいバッファとして取得するかを指定
        inputNode.installTap(onBus: 0, bufferSize: 512, format: recordingFormat) { (buffer, when) in
            recognitionRequest.append(buffer)
        }

        audioEngine.prepare()
        try audioEngine.start()

        DispatchQueue.main.async {
            self.state = .recording(inputNode, recognitionTask)
        }
    }

    private func handleSilenceTimer() {
        silenceTimer?.invalidate()

        silenceTimer = Timer.scheduledTimer(withTimeInterval: waitsForResponseSec, repeats: false) { _ in
            DispatchQueue.main.async {
                self.stop()
                self.isInputFinished = true
            }
        }
    }
}

AWS Pollyを使った音声出力の実装

ここでは深層学習による音声出力サービスのAWS Pollyの実装について解説します。

まずは、AWSの認証情報をAppDelegate等で設定する必要がありますが、ここでも重要な点があります。それは設定時のregionです。

ここのリージョンをを実際に利用するユーザと一番違いものを選択することで、AWS Pollyの応答速度を向上することができます

import Foundation
import UIKit
import AWSPolly

class AppDelegate: NSObject, UIApplicationDelegate {

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
      let accessKey = CredentialManager.shared.value(for: .awsAccessKey)
      let secretKey = CredentialManager.shared.value(for: .awsSecretKey)
      let credentialsProvider = AWSStaticCredentialsProvider(accessKey: accessKey, secretKey: secretKey)
 
      // AWS Pollyを利用するための設定
      // NOTE: 応答レスポンスの速度向上のため違いリージョンを選択すること e.g. APNortheast1
      AWSServiceManager.default().defaultServiceConfiguration = AWSServiceConfiguration(region: .APNortheast1, credentialsProvider: credentialsProvider)

      return true
  }
}

続いてAWS Pollyを実際に実装している箇所についてですが、ここはAWSPollySynthesizeSpeechInputに対して設定を行い、synthesizeSpeechを呼び出すことで音声ストレームを取得することができます。
これをそのままAVPlayerで再生することで音声出力が実現できます。

また、今回は自然な発音にもこだわっているためニューラルTTSのモデルは英語モデルの一番最初にあるivyというキャラクターで設定しています。
ここのモデル名を変更することで、任意の言語に変更ができ日本語を含めた20ヶ国語以上に対応することができます。
https://docs.aws.amazon.com/ja_jp/polly/latest/dg/ntts-voices-main.html

import Combine
import Foundation
import AVFoundation
import AWSPolly

@MainActor
final class TextToSpeechViewModel: NSObject, ObservableObject {
    
    private var player: AVAudioPlayer?
    private var playCompletion: ((Result<Void, Never>) -> Void)?

    private let polly = AWSPolly.default()
    private let audioSession = AVAudioSession.sharedInstance()

    override init() {}

    func convertTextToSpeech(text: String) -> AnyPublisher<Void, Never> {
        return Future<Void, Never> { [weak self] promise in
            guard let me = self, let input = AWSPollySynthesizeSpeechInput() else {
                promise(.success(()))
                return
            }

            me.playCompletion = promise

            // 詳細はAWSPollyのSampleなどを参照: https://github.com/awslabs/aws-sdk-ios-samples/tree/main/Polly-Sample/Swift
            // engineをneuralにそれに対応したvoiceIdを選択することによって、自然な発音にトレーニングされたモデルを利用することができる
            input.text = text
            input.outputFormat = .mp3
            input.engine = .neural
            input.textType = .text
            input.voiceId = .ivy
            
            me.polly.synthesizeSpeech(input) { (response, error) in
                if let audioStream = response?.audioStream {
                    guard let me = self else { return }
                    me.player = try? AVAudioPlayer(data: audioStream)
                    me.player?.delegate = me

                    do {
                        try me.audioSession.setCategory(.playback, mode: .default)
                        try me.audioSession.setActive(true)
                    } catch let error {
                        print("Error setting up audio session: \(error.localizedDescription)")
                    }
                    me.player?.play()
                } else {
                    print("Error occurred: \(error?.localizedDescription ?? "Unknown error")")
                    me.playCompletion?(.success(()))
                    me.playCompletion = nil
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

extension TextToSpeechViewModel: AVAudioPlayerDelegate {
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        playCompletion?(.success(()))
        playCompletion = nil
    }
}

ChatGPT-3.5を使った会話のレスポンス生成

最後に解説するのはChatGPTを利用した英会話のレスポンス生成についてです。
まずはChatGPT APIを利用するためにAPIの実装をしますが、ここはシンプルにリクエストを投げるだけで問題ありません。

英会話において重要なところとしては、以下のリクエストパラメータです。
現状は英会話向けに最適化な設定かと言われるとそうでもないので、ここはニーズに応じてカスタマイズできると良いと思います。

パラメータ 解説
model gpt3.5やgpt4を選択できる。応答速度重視 or 正確性重視
top_p 0~1。低いほど文法が正確で確定的、高いほど多様な文章表現
n 1~3。AIが返すレスポンスの数
max_tokens 0~n。AIが返す文字数(トークン単位)の上限
frequency_penalty -2~2。低いほど同じ単語を繰り返さなくなり、高いほど同じ単語を使いやすくなります。何回使ったかによってペナルティが付与されます。
presence_penalty -2~2。↑と同じ。1回でも出現したらペナルティが付与される違いがあります
import Foundation
import Combine

class OpenAIAPI {
    private let apiKey = CredentialManager.shared.value(for: CredentialKey.openAiKey)
    private let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")!

    func generateResponse(messages: [[String: String]]) -> AnyPublisher<ChatCompletion, Error> {
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

        let parameters: [String: Any] = [
            "model": "gpt-3.5-turbo", // 正確性よりも応答速度を重視するモデル
            "messages": messages,
            "top_p": 1,
            "n": 1,
            "max_tokens": 25, // 返却される最大トークン数を小さくすることで会話をスムーズにする
            "frequency_penalty": 1.5, // 同じ単語ばかりが返されないようにランダム性を向上する (-2~2, 2が最もランダム)
            "presence_penalty": 1
        ]

        do {
            let jsonData = try JSONSerialization.data(withJSONObject: parameters, options: .prettyPrinted)
            request.httpBody = jsonData
        } catch {
            return Fail(error: error).eraseToAnyPublisher()
        }

        return URLSession.shared.dataTaskPublisher(for: request)
            .tryMap { data, response -> Data in
                guard let httpResponse = response as? HTTPURLResponse,
                    200..<300 ~= httpResponse.statusCode else {
                        throw URLError(.badServerResponse)
                }
                return data
            }
            .decode(type: ChatCompletion.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

続いて会話のレスポンス生成の実装部分についてですが、重要な点としては2点あります。

1つはmaxContextCountです、これはAIが何個前までの会話を覚えているかの設定になります。ここがたとえば0だと1つ前にどういう話をしていたかを覚えていないので会話の遡りや文脈の理解度が低下します。そのため小さすぎず、大きすぎない適切な値を設定することで英会話の体験をよくすることができます。

2つめが、役割の設定です。ChatGPT APIにはロールと呼ばれる概念があり、以下の3つが存在します。

ロール 解説
System AIの設定を指示するフィールド
User ユーザーの入力
Assistant AIが回答した出力

上記のロールを設定することによってChatGPTは適切なレスポンスを回答できます。
特にレスポンス内容に影響するのでSystemロールです、今回は以下の指示を与えています。

"これは英会話の練習です。簡潔な回答をしつつ、会話を続けるために適宜質問もしてください"

これによりAIはユーザーの入力は英会話の練習であることを認識し、会話を続けるために質問を交えながら進行してくれます。 ここに追加の指示も与えることでどういった性格で振る舞ってほしいかや、どんな学習プランにしたいかなども反映することができます。

import Foundation
import Combine

final class TextCompletionViewModel: ObservableObject {

    @Published private(set) var talkHistories: [TalkHistory] = []

    private let apiClient: OpenAIAPI

    // 会話の履歴を最大何個まで保持するか
    private let maxContextCount = 3

    init(apiClient: OpenAIAPI) {
        self.apiClient = apiClient
    }

    func addAssistantHistory(content: String) {
        talkHistories.append(TalkHistory(role: .assistant, content: content))
    }

    func clearTalkHistory() {
        talkHistories = []
    }

    // システムロールを任意の数追加する
    // ここでは会話の練習のためにレスポンスは短く返すように指示を入れている
    private func makeSystemPrompts() -> [TalkHistory] {
        return [
            TalkHistory(role: .system, content: "This is intended for English conversation practice. Please provide concise responses and ask relevant questions to keep the conversation going")
        ]
    }
    
    func generateResponse(prompt: String) -> AnyPublisher<ChatCompletion, Error> {
         talkHistories.append(TalkHistory(role: .user, content: prompt))

         if talkHistories.count > maxContextCount {
             talkHistories.removeFirst()
         }

         let messages = (makeSystemPrompts() + talkHistories)
             .map { prompt in ["role": prompt.role.rawValue, "content": prompt.content] }

         return apiClient.generateResponse(messages: messages)
     }
}

アプリの実行

アプリを起動すると、一番最初に音声入力の許諾ダイアログが表示されますが一度だけ許可すれば次回からはアプリを立ち上げた時点から音声入力状態となり、会話をすることができます。

アプリの使い方:アプリ実行結果
アプリ実行結果

ここまできたらあとはカスタマイズを楽しむだけです!
最初は英語ではなくあえて日本語で会話するのもよし、自分だけの学習プランを用意してもらって実践的に使うもよしです。

まとめ

今回は英語学習アプリを自作する方法について紹介しました。

ChatGPTの到来により、まさに今後の教育や言語学習のやり方は進化していくと思います。

今回はその実験として作成してみましたが、実際に使ってみることですぐに実践的な学習にも利用しながら、まだまだ機能の改善・カスタマイズの余地はあるので1エンジニアの観点からもとても面白かったです。

引き続き、今後もAIを活用した事例や挑戦があれば発信をしていこうと思います。
本記事の内容がお役に立てることがあれば幸いです。

参考:

Discussion