LoginSignup
0
0

iOS に 日本語を しゃべらせる(macOS / iPadOSも同じ)

Posted at

OSネイティブによる音声合成

iOS / iPadOS / macOS は、数ステップのコードにて、日本語を簡単にしゃべらせることができます。
その方法を説明します。

Speech Synthesis

OS標準のAVFoundationライブラリで提供されているSpeech Synthesis機能を使います。

API

たった3つのAPIを呼び出すだけです。

使用例1

最も簡単なAPI使用例は以下の通り。

import AVFoundation

let speechText = "この言葉をしゃべります"

let utterance = AVSpeechUtterance(string: speechText)
utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP") //日本語で
//utterance.volume = 1.0
//utterance.rate = 0.5
//utterance.pitchMultiplier = 2.0

let speechSynthesizer = AVSpeechSynthesizer()
speechSynthesizer.speak(utterance) //しゃべります

//RunLoop.current.run(until: Date(timeIntervalSinceNow: 5.0)) //for swift repl

SpeechUtteranceのパタメタは以下の通り。必要により、しゃべらせる前に設定します。

パタメタ 内容 範囲
volume 音量 無音:0.0 〜 最大:1.0
デフォルト:1.0
rate 速度 AVSpeechUtteranceMinimumSpeechRate 〜 AVSpeechUtteranceMaximumSpeechRate
デフォルト:AVSpeechUtteranceDefaultSpeechRate
pitchMultiplier ピッチ 低い:0.4 〜 高い:2.0
デフォルト:1.0

自分の環境だと次の値でした。
  AVSpeechUtteranceMinimumSpeechRate:0.0
  AVSpeechUtteranceMaximumSpeechRate:1.0
  AVSpeechUtteranceDefaultSpeechRate:0.5

speechSynthesizer.speak()は非同期にしゃべります。
このため、swift repl で実行する場合は、RunLoop.current.run()が必要。
また、しゃべっている時に続けてspeak()すると(要求はキューイングされ)、前の言葉がしゃべり終わると、続けてしゃべります。

しゃべり終わりやその他のタイミングをつかみたい場合は、AVSpeechSynthesizerDelegate を実装します。

AVSpeechSynthesizerDelegate
//おしゃべりを開始するタイミング
func speechSynthesizer(AVSpeechSynthesizer, didStart: AVSpeechUtterance)

//おしゃべりテキストの一部をしゃべろうとしているとき
func speechSynthesizer(AVSpeechSynthesizer, willSpeakRangeOfSpeechString: NSRange, utterance: AVSpeechUtterance)

//おしゃべりのマーカーをしゃべろうとしているとき
func speechSynthesizer(AVSpeechSynthesizer, willSpeak: AVSpeechSynthesisMarker, utterance: AVSpeechUtterance)

//おしゃべり中に一時停止するタイミング
func speechSynthesizer(AVSpeechSynthesizer, didPause: AVSpeechUtterance)

//一時停止後におしゃべりを再開するタイミング
func speechSynthesizer(AVSpeechSynthesizer, didContinue: AVSpeechUtterance)

//おしゃべりを終了したとき
func speechSynthesizer(AVSpeechSynthesizer, didFinish: AVSpeechUtterance)

//おしゃべりをキャンセルするタイミング
func speechSynthesizer(AVSpeechSynthesizer, didCancel: AVSpeechUtterance)

使用例2

AVSpeechSynthesizerDelegate の実装例は以下の通り。

import AVFoundation

let speechText = "エーヴイスピーチシンセサイザーは、テキストの発話から合成音声を生成し、進行中の音声の監視または制御を可能にするオブジェクトです"

let utterance = AVSpeechUtterance(string: speechText)
utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")

let delegate = SpeechSynthesizerDelegateImplement()
let speechSynthesizer = AVSpeechSynthesizer()
speechSynthesizer.delegate = delegate

speechSynthesizer.speak(utterance)

// AVSpeechSynthesizerDelegate
class SpeechSynthesizerDelegateImplement: NSObject, AVSpeechSynthesizerDelegate {
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        print("didStart")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        let start = speechText.index(speechText.startIndex, offsetBy: characterRange.location)
        let end = speechText.index(start, offsetBy: characterRange.length)
        let textPart = speechText[start ..< end]
        print("willSpeakRangeOfSpeechString", characterRange, textPart)
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeak marker: AVSpeechSynthesisMarker, utterance: AVSpeechUtterance) {
        print("willSpeak")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
        print("didPause")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
        print("didContinue")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        print("didFinish")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        print("didCancel")
    }
}

おしゃべりの途中で停止、一時停止 / 再開させる場合は、AVSpeechSynthesizerの次の関数を使います。

//指定した区切りでしゃべりを停止する
func stopSpeaking(at: AVSpeechBoundary) -> Bool

//指定した区切りでしゃべりを一時停止する
func pauseSpeaking(at: AVSpeechBoundary) -> Bool

//一時停止した時点からしゃべりを再開する
func continueSpeaking() -> Bool

//それぞれ、成功するとtrue

停止や一時停止する時の区切り (AVSpeechBoundary) は、次の2通りが指定できます。

  • immediate:しゃべりをただちに一時停止または停止する
  • word:現在の単語の発話を終了した後に一時停止または停止する

また、おしゃべり中と一時停止中を判定するには、AVSpeechSynthesizerの次のプロパティを参照します。

var isSpeaking: Bool //おしゃべり中はtrue
var isPaused: Bool   //一時停止中はtrue

使用例3

これまでのすべてのAPIを試すことができるアプリのコードを以下に示します。
Xcodeのプレビューでも動作確認できます。

import AVFoundation
import SwiftUI

struct ContentView: View {
    let speechSynthesizer = SpeechSynthesizer()
    @State var text = ""
    @State var isPaused = false
    @State var speechString = ""
    @State var speechText: AttributedString = ""
    @State var volume: Float = 1.0
    @State var rate: Float = AVSpeechUtteranceDefaultSpeechRate
    @State var pitchMultiplier: Float = 1.0
    @State var speechBoundary = AVSpeechBoundary.immediate
    @FocusState var focus: Bool
    var body: some View {
        List {
            Stepper(value: $volume, in: 0.0 ... 1.0, step: 0.1) {
                HStack {
                    Text("Volume:")
                    Spacer()
                    Text("\(volume, specifier: "%.1f")")
                }
            }
            Stepper(value: $rate, in: AVSpeechUtteranceMinimumSpeechRate ... AVSpeechUtteranceMaximumSpeechRate, step: 0.1) {
                HStack {
                    Text("Rate:")
                    Spacer()
                    Text("\(rate, specifier: "%.1f")")
                }
            }
            Stepper(value: $pitchMultiplier, in: 0.4 ... 2.0, step: 0.1) {
                HStack {
                    Text("PitchMultiplier:")
                    Spacer()
                    Text("\(pitchMultiplier, specifier: "%.1f")")
                }
            }
            HStack {
                Text("")
                Spacer()
                Text("今日の日付 を しゃべる")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        let now = Date.now
                        let calendar = Calendar(identifier: .japanese)
                        
                        let dateFormatter = DateFormatter()
                        dateFormatter.locale = Locale(identifier: "ja_JP")
                        dateFormatter.calendar = calendar
                        dateFormatter.dateFormat = "Gy EEEE"
                        let wareki = dateFormatter.string(from: now).split(separator: " ")
                        
                        let dateComponents = calendar.dateComponents([.year, .month, .day, .weekday], from: now)
                        guard let month = dateComponents.month, let day = dateComponents.day else { return }
                        
                        speechString =  "\(wareki[0])\(correctionMonth(month))\(correctionDay(day))\(wareki[1])"
                        speechSynthesizer.speak(string: speechString, volume: volume, rate: rate, pitchMultiplier: pitchMultiplier)
                    }
            }
            HStack {
                Text("")
                Spacer()
                Text("現在時刻 を しゃべる")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        let calendar = Calendar.current
                        let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: Date.now)
                        guard let hour = dateComponents.hour, let minute = dateComponents.minute, let second = dateComponents.second else { return }
                        
                        speechString = minute == 0 && second == 0 ? "\(hour)時ちょうど" : "\(hour)\(minute)\(second)秒"
                        speechSynthesizer.speak(string: speechString, volume: volume, rate: rate, pitchMultiplier: pitchMultiplier)
                    }
            }
            HStack {
                Text("")
                TextField("自由テキスト", text: $text)
                    .textFieldStyle(.roundedBorder)
                    .focused($focus)
                Spacer()
                Text("を しゃべる")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        focus = false
                        speechString = text
                        if text.isEmpty { speechString = "空欄です。テキストを入力してください" }
                        speechSynthesizer.speak(string: speechString, volume: volume, rate: rate, pitchMultiplier: pitchMultiplier)
                    }
            }
            Picker("SpeechBoundary", selection: $speechBoundary) {
                Text("immediate").tag(AVSpeechBoundary.immediate)
                Text("word").tag(AVSpeechBoundary.word)
            }
            .pickerStyle(.menu)
            
            HStack {
                Text("isSpeaking:")
                Text("\(speechSynthesizer.speechSynthe.isSpeaking)")
                    .foregroundColor(speechSynthesizer.speechSynthe.isSpeaking ? .red : .textColor)
                Spacer()
                Text("Stop")
                    .foregroundColor(speechSynthesizer.speechSynthe.isSpeaking ? .accentColor : .gray)
                    .disabled(!speechSynthesizer.speechSynthe.isSpeaking)
                    .onTapGesture  {
                        if speechSynthesizer.speechSynthe.isSpeaking {
                            speechSynthesizer.speechSynthe.stopSpeaking(at: speechBoundary)
                            isPaused = false
                        }
                    }
            }
            HStack {
                Text("isPause:")
                Text("\(isPaused)")
                    .foregroundColor(isPaused ? .red : .textColor)
                Spacer()
                Text(isPaused ? "Continue" : "Pause")
                    .foregroundColor(Color.accentColor)
                    .onTapGesture  {
                        isPaused = speechSynthesizer.speechSynthe.isPaused
                        if isPaused {
                            speechSynthesizer.speechSynthe.continueSpeaking()
                        } else {
                            speechSynthesizer.speechSynthe.pauseSpeaking(at: speechBoundary)
                        }
                        isPaused.toggle()
                    }
            }
            Text(speechText)
        }
        .listStyle(.plain)
        .padding()
        .onAppear {
            speechSynthesizer.set(text: $speechText)
        }
    }
    private func correctionMonth(_ month: Int) -> String {
        let tsuki = "0 1 2 3 し 5 6 7 8 9 10 11 12".split(separator: " ")
        return "\(tsuki[month])がつ"
    }
    private func correctionDay(_ day: Int) -> String {
        let hiduke = ",一日,二日,三日,四日,五日,六日,七日,八日,九日,とうか"
            .split(separator: ",", omittingEmptySubsequences: false)
        return switch day {
            case 1 ..< 11: "\(hiduke[day])"
            case 20: "二十日"
            case 24: "二十よっか"
            default: "\(day)にち"
        }
    }
}

class SpeechSynthesizer: NSObject {
    let speechSynthe = AVSpeechSynthesizer()
    var speakText: Binding<AttributedString>? = nil
    override init() {
        super.init()
        speechSynthe.delegate = self
    }
    func set(text: Binding<AttributedString>) {
        speakText = text
    }
    func speak(string: String, volume: Float, rate: Float, pitchMultiplier: Float) {
        let utterance = AVSpeechUtterance(string: string)
        utterance.volume = volume
        utterance.rate = rate
        utterance.pitchMultiplier = pitchMultiplier
        utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")
        speechSynthe.speak(utterance)
    }
}
extension SpeechSynthesizer: AVSpeechSynthesizerDelegate {
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
        print("didStart")
        guard speakText != nil else { return }
        speakText!.wrappedValue = AttributedString(utterance.speechString)
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
        let speechText = utterance.speechString
        let textPart = speechText[Range(characterRange, in: speechText)!]
        print("willSpeakRangeOfSpeechString", characterRange, textPart)

        var attributeString = AttributedString(utterance.speechString)
        guard speakText != nil else { return }

        speakText!.wrappedValue = attributeString
        if let range = Range(characterRange, in: attributeString) {
            attributeString[range].foregroundColor = .red
            speakText!.wrappedValue = attributeString
            print(range, attributeString[range])
        }
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeak marker: AVSpeechSynthesisMarker, utterance: AVSpeechUtterance) {
        print("willSpeak", marker)
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
        print("didPause")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
        print("didContinue")
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        print("didFinish")
        guard speakText != nil else { return }
        speakText!.wrappedValue = ""
    }
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        print("didCancel")
        guard speakText != nil else { return }
        speakText!.wrappedValue = ""
    }
}

extension Color {
#if os(macOS)
    static let textColor = Color(nsColor: .textColor)
#else
    static let textColor = Color(.label)
#endif
}

#Preview {
    ContentView()
#if os(macOS)
        .frame(width: 300)
#endif
}
app.png iPhone.png

しゃべらせてみ

次の文章をしゃべらせてみました。

  • アプリおよびWebサイトの中には、Macの画面やオーディオにアクセスしたりそれを収録したりできるものがあります。画面収録やオーディオ録音を許可するアプリおよびWebサイトを指定できます。

少しぎこちないですね。
しかし、同じ文章でも 『翻訳後のスピーチ』(↓)では、もう少し上手にしゃべります。音量も大きい。サーバで処理しているからでしょうか、しゃべり出すまでに少し待たされます。(下記動画は待ち無しに加工済み)

この違いは、Mac も iPhone もまったく同じでした。

以上です

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0