blog.takurinton.dev

音声検索β

2020-10-10

こんにちは

こんにちは。僕です。

今日はブログに音声検索の機能を実装したのでつまづいたところ、うまく行ったところ、逆にうまくいかなくて現状では実装できていないところなどを書いていきたいと思います。

全体のコードは[こちら](https://github.com/takurinton/takurinton.com)にあります。

実装する

実装ですが、今回は文字起こしまでをフロントエンド、形態素解析と処理はバックエンドで実装しました。

フロントエンドでも文字起こしはできたのですが、形態素解析から処理までの流れを一連の動き(型や言語なども含めて)にしたかったためバックエンドで実装しました。

そのうち余裕があったらフロントエンドでの形態素解析もやってみたいと思います。

全体像

上でちょろっと説明しましたが、今回の音声検索機能の全体像はこんな感じになっています。

フロントエンドで文字起こし、バックエンド側で解析をしています。

こんな感じで簡単にですが実装しました。

Speech Recoginitionで文字起こし

文字起こしは[Web Speech Recognition](https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API)を使用していきます。

これの対応度合いがこのザマなので、これが正攻法とは言えないかもしれませんが、今回は使いやすいってだけでこいつをチョイスしました。

Reactで実装

Reactで実装する際に、音声認識の部分とコンポーネントの部分は切り離して実装したかったため、srcの下にmethod/speech/recognition.tsというファイルを作成してそこに音声認識の部分の定義をしていきました。

// recognition.ts

export class Recognition {
    recognition: SpeechRecognition = new (window as any).webkitSpeechRecognition();
    running = false;
  
    onFinal?: (str: string) => void;
    onProgress?: (str: string) => void;
    onError?: () => void;
  
    constructor() {
      this.recognition.continuous = true;
      this.recognition.interimResults = true;
  
      this.recognition.onresult = (event) => {
        for (let i = event.resultIndex; i < event.results.length; ++i) {
          if (event.results[i].isFinal) {
            if (this.onFinal) this.onFinal(event.results[i][0].transcript);
          } else {
            // eslint-disable-next-line no-lonely-if
            if (this.onProgress) this.onProgress(event.results[i][0].transcript);
          }
        }
      };
  
      this.recognition.onerror = (event) => {
        console.warn(event);
        if (this.onError) this.onError();
      };
  
      this.start();
    }
  
    start() {
      this.running = true;
      this.recognition.start();
    }
  
    stop() {
      this.running = false;
      this.recognition.stop();
    }
  
    toggle() {
      if (this.running) {
        this.stop();
      } else {
        this.start();
      }
    }
  }

クラスにすることで全てのメソッドを簡単に利用することができるようにしました。startをすると音声認識を開始、stopをすると止める、toggleでそれぞれを切り替えられるようにしました。

特にインポートなしのデフォルトの構成でここまで組めてしまうのはすごいなと思います。

次にsrc/component/pages/Search.tsxを作成し、コンポーネントを作っていきます。

// Search.tsx

import React, { 
  useEffect,
  useRef,
  useState,
} from 'react'

import Box from '@material-ui/core/Box'

import { PostProps } from '../../type/props'

import { Recognition } from '../../method/speech/recognition'
import { Post } from '../parts/Post'

import { DetailStyle } from '../../style/detail'
import { ErrorStyle } from '../../style/error'

import {postSearch} from '../../method/api/postSearch'
import Loading from '../../component/pages/Loading'
import useSpeech from '../..//hooks/useSpeech'

import { Input, Button, Typography } from '@material-ui/core'

const Search = () => {
    const classes = DetailStyle()
    const subClasses = ErrorStyle()
    const recognition = useRef();
    const [input, setInput] = useState('')
    const [initial, setInitial] = useState(true)
    const [loading, setLoading] = useState(false)
    const [post, setPost] = useState([])
    const {
        handleChange, 
        handleSubmit, 
        state
    } = useSpeech()

    const onStart = () => {
        setLoading(true)
        setInitial(false)
        const r = (recognition.current = new Recognition());
        r.onFinal = (c) => {
        r.toggle()
        state.content = c
        postSearch(state)
        .then((p) => {
            setPost(p)
            setInput(c)
            setLoading(false)
        })
        .catch(() => {
            console.log('error')
        })
        }
    }

    const onStop = () => {
        const r = (recognition.current = new Recognition());
        r.stop()
    }

    return ( 
        
            {/*  */}
            
            
            {/* 
*/} {initial ? ( startを押してキーワードを喋ってください ) : ( loading ? ( 録音中... ) : ( " {input} " {post.map((p:PostProps) => )} ) ) }
) } export default Search

ちょっと長くなってしまいましたが、こんな感じで実装しています。

ざっくりとした構成を説明すると、onStartが呼ばれるとtoggleが発火して録音が開始されます。文章が途切れたと判断されたらtoggleが切り替わり、その時点でバックエンド側にリクエストが投げられます。そのレスポンスを引数として受け取り、setPostすることでpostに検索結果のpost一覧が入るようになっています。

onStartメソッドはボタンが押されたときに実行されるようにしました。

このAPIを叩くためのメソッドはsrc/method/api/postSearch.tsに定義しています。

// postSearch.ts
const toJson = async (res:Response) => {
    const js = await res.json()
    if (res.ok) {
        return js
    } else {
        throw new Error(js.message)
    }
}

export const postSearch = async (body:object) => { 
    const resp = await fetch(`https://takurinton.com/blog/search`, {
        method: "POST",
        headers: {
            'Content-Type': 'application/json'
        },
        credentials: "same-origin",
        body: JSON.stringify(body), 
    })
    
    return await toJson(resp)
}

fetch APIを使用しています。理由としては、標準で使えてinstallが必要ないため楽に使用できると感じたからです。良き。

フロントエンドの実装はこんな感じで次はバックエンドでどのような処理をしているかを書いていきます。

MeCabで形態素解析

形態素解析はMeCabを使用して実装しました。Pythonでの形態素解析はJanomeなどの選択肢もありましたが、自分が使用したことあること、シンプルでわかりやすいことなどからMeCabにしました。

Djangoでは処理(コントローラー的なポジション)はviews.pyというファイルに記述するのが一般的ですが、形態素解析は分離したかったのでviews.pyと同じ階層にfuncというディレクトリを作成し、その中にmorphological_analysis.pyというファイルを作成してそこで形態素解析を行いました。

# morphological_analysis.py 

import MeCab

def mecab(text):
    m = MeCab.Tagger('-Ochasen')
    return [i.split()[0] for i in m.parse(text).splitlines() if "名詞" in i.split()[-1]]

こんな感じで実装しました。

MeCabのオプションはchasenを使いました。これは品詞なども抽出してくれます。今回は検索用に使うので名詞のみを抽出したかったのでこのような形式にしました。

また、辞書はipadicを使用しました。

戻り値はリクエストのbodyの中に入っている文章の名詞のみを抽出したリストを返しています。

次にviews.pyを見てみましょう。

# views.py
...
from .func.morphological_analysis import mecab

class SearchAPI(APIView):
    def post(self, request):
        try:
            content = request.data['content'] # ここでbodyからcontentを取り出している
            c = mecab(content) # 先ほど定義した関数を使用してリストを受け取る
            category = Category.objects.get(name=c[0])
            post = Post.objects.filter((
		Q(title__icontains=content) | Q(category=category) ), 
		open=True).order_by('pub_date').reverse()
            post_response = [
                {
                    'id': i.id, 
                    'title': i.title, 
                    'category': i.category.name, 
                    'contents_image_url': i.contents_image_url, 
                    'pub_date': i.pub_date, 
                } 
                for i in post
            ]
            return Response(post_response) # レスポンス
        except:
            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
...

こんな感じになりました。本当はたくさん検索したかったのですが、現状のプログラムだと厳しくなってしまったため、とりあえず一番最初の名詞のみを抽出してそれとマッチングする投稿を探すようにしています。

LIKE句での検索にしてるのでいい感じに取り出してはくれますが、名詞が2つきてしまったり、人間の言葉の意味解析的な部分が求められた時は何もできません。

ここら辺を意味解析や投稿の文脈推定などを利用してどんどんリファクタリングしていけたらいいなと思います。

まとめ

最後になりますが、今回の実装は本当に脳死でできるくらい簡単です。本当はもっとNLPチックなことしたかったのですが、自分の知識量では無理でした。

しかし、興味がある分野なので自分で勉強をして、音声検索から意味解析、またブログの本文から意味をなんとなく読み取り、記事のレコメンドができるようなシステムの構築をできるように頑張っていきたいと思っています。

あとは、ここらへんの昨日はまだまだ不安定な部分が多かったりするので原因を追及してより安定したプログラムを書いていけるように頑張ります。

最後までお読みいただきありがとうございました。