Azureの仮想マシン＋StreamlitでAzure AI Speech to Textを試す

# はじめに
仕事でオフラインの文字起こしのニーズが多い。既存の文字起こしサービスはたくさんありますが、①情報の取り扱いを主体的にコントロールするニーズがあること、②精度の高いといわれるAzure AI Speechの性能を評価したいということで、自作しました。

↓のように書き起こした文書を生成AIで整形等することも見据えています。

https://www.qes.co.jp/media/open-ai/a353

## Azure AI SpeechかAzure OpenAI Whisperか

価格比較、性能比較等、以下のページで解説いただいているので詳細は書きませんが、Whisperのファイル長の制限（25MB）が後々ネックになりそうだったので、AI Speechを選択

https://zenn.dev/headwaters/articles/42b32a1ebe935e



# 環境構築手順
### 1. Azureの仮想マシンを作成
Azure Portalから仮想マシンを作成。スペックは最低限にするため、月６千円程度の最低限のものを選択。OSはWindows2022Serverを選択。


### 2.1 Pythonのinstall
以下のページから3.13をインストール。

https://www.python.org/downloads/windows/

メモ：Pythonのルートフォルダと\ScriptsにPathを通す。


### 2.2 パッケージのインストール
pipを使って以下のパッケージをインストール

~~~
pip install azure-cognitiveservices-speech
pip install streamlit
pip install pydub
pip install audioop-lts
~~~

### 2.3  Visual Studio 2015、2017、2019、2022 の Microsoft Visual C++ 再頒布可能パッケージのインストール

これをしないと、azure-cognitiveservices-speech関連のDLL読み込み時にエラーが出てしまいます。以下からインストールする。

https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/quickstarts/setup-platform?tabs=windows%2Cubuntu%2Cdotnetcli%2Cdotnet%2Cjre%2Cmaven%2Cnodejs%2Cmac%2Cpypi&pivots=programming-language-python

### 2.4 FFMPEGのインストール

こちらはmp3を入力源にした処理する際に必要な手順。やっておかないと以下のエラーが出た。

~~~
FileNotFoundError: [WinError 2] The system cannot find the file specified
~~~

https://qiita.com/Tadataka_Takahashi/items/9dcb0cf308db6f5dc31b

### 2.5 Gstreamerのインストール
こちらもMP3を処理する際に必要な手順。
やらないと以下のエラーが出た。

~~~
SPXERR_GSTREAMER_NOT_FOUND_ERROR
~~~

以下のページからインストールを行うこと。

https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-use-codec-compressed-audio-input-streams?tabs=windows%2Cdebian%2Cjava-android%2Cterminal&pivots=programming-language-python

### 2.6 Webアプリへのアクセスを許可するための手順

* Azureのセキュリティグループで80番ポート受信ルールの追加 
* 仮想マシン側のWindows Defenderにおける80番ポート受信ルールの作成

メモ：Streamlitの80番ポートの起動は"streamlit run app.py --server.port 80"

# Webアプリの構築
以下のStreamlit + Azure AIのサンプルをベースにコーディングを行いました。

https://medium.com/@contact.blessin/deploying-azure-ai-services-using-streamlit-speech-to-text-295641c5de10

以下も大変参考になりました。

https://tech.ledge.co.jp/entry/2021/03/09/120000

話者分離については以下を参考にしています。

https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/get-started-stt-diarization?tabs=windows&pivots=programming-language-python

StreamlitのUIからファイルをアップロード、言語、話者分離の有無を選択したうえで、Startを押下することで文字起こしをしてくれます。

非常に汚いコードでとりあえず動くもの、です。諸々ご容赦ください。

なお、裏側でWAVに変換してAI Speechにいれるか、MP3に変換していれるかを選択できるようにしています。が、処理結果は同じです。行きがかり上作っただけであまり意味はありません。また、中間ファイルの削除処理も完全ではありません。

~~~app.py
import time
import os
import datetime
import azure.cognitiveservices.speech as speechsdk
import streamlit as st
from pydub import AudioSegment

# Set up Azure Speech Service credentials
speech_key = "REPLACETOYOURAZUREAPIKEY"
service_region = "REPLACETOYOURAZUREREGION"
done = False
overWrite = ""

#####################################################################################
########################### Class 定義 for MP3(話者分離) ############################
#####################################################################################

class BinaryFileReaderCallback(speechsdk.audio.PullAudioInputStreamCallback):
    def __init__(self, filename: str):
        super().__init__()
        self._file_h = open(filename, "rb")

    def read(self, buffer: memoryview) -> int:
        print('trying to read {} frames'.format(buffer.nbytes))
        try:
            size = buffer.nbytes
            frames = self._file_h.read(size)

            buffer[:len(frames)] = frames
            print('read {} frames'.format(len(frames)))

            return len(frames)
        except Exception as ex:
            print('Exception in `read`: {}'.format(ex))
            raise

    def close(self) -> None:
        print('closing file')
        try:
            self._file_h.close()
        except Exception as ex:
            print('Exception in `close`: {}'.format(ex))
            raise

#####################################################################################
############################# MP3文字起こし(話者分離) ###############################
#####################################################################################
def compressed_stream_helper_transcribe(output, speech_key, service_region, compressed_format,
        mp3_file_path
        ):
    callback = BinaryFileReaderCallback(mp3_file_path)
    stream = speechsdk.audio.PullAudioInputStream(stream_format=compressed_format, pull_stream_callback=callback)

    speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=service_region)
    if lng == "Japanese":
        speech_config.speech_recognition_language = "ja-JP"
    else:
        speech_config.speech_recognition_language="en-US"
 
    speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_DiarizeIntermediateResults, value='true')

    audio_config = speechsdk.audio.AudioConfig(stream = stream)
    conversation_transcriber = speechsdk.transcription.ConversationTranscriber(speech_config=speech_config, audio_config=audio_config)

    transcribing_stop = False
    Ofst = 0.0
    block=""
    def conversation_transcriber_recognition_canceled_cb(evt: speechsdk.SessionEventArgs):
        print('Canceled event')

    def conversation_transcriber_session_stopped_cb(evt: speechsdk.SessionEventArgs):
        print('SessionStopped event')

    def conversation_transcriber_transcribed_cb(evt: speechsdk.SpeechRecognitionEventArgs):
        print('\nTRANSCRIBED:')
        if evt.result.reason == speechsdk.ResultReason.RecognizedSpeech:
            print('\tText={}'.format(evt.result.text))
            print('\tSpeaker ID={}\n'.format(evt.result.speaker_id))
        elif evt.result.reason == speechsdk.ResultReason.NoMatch:
            print('\tNOMATCH: Speech could not be TRANSCRIBED: {}'.format(evt.result.no_match_details))
        nonlocal block
        block += '\r\nSpeaker ID={}'.format(evt.result.speaker_id)+ ' [Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        block += evt.result.text+'\r\n'
        nonlocal output
        output+='\r\nSpeaker ID={}'.format(evt.result.speaker_id)+ ' [Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        output+= evt.result.text+'\r\n'

    def conversation_transcriber_transcribing_cb(evt: speechsdk.SpeechRecognitionEventArgs):
        print('TRANSCRIBING:')
        print('\tText={}'.format(evt.result.text))
        print('\tSpeaker ID={}'.format(evt.result.speaker_id))
        nonlocal Ofst
        Ofst = evt.result.offset/10000000

    def conversation_transcriber_session_started_cb(evt: speechsdk.SessionEventArgs):
        print('SessionStarted event')
    def stop_cb(evt: speechsdk.SessionEventArgs):
        #"""callback that signals to stop continuous recognition upon receiving an event `evt`"""
        print('CLOSING on {}'.format(evt))
        nonlocal transcribing_stop
        transcribing_stop = True

    # Connect callbacks to the events fired by the conversation transcriber
    conversation_transcriber.transcribed.connect(conversation_transcriber_transcribed_cb)
    conversation_transcriber.transcribing.connect(conversation_transcriber_transcribing_cb)
    conversation_transcriber.session_started.connect(conversation_transcriber_session_started_cb)
    conversation_transcriber.session_stopped.connect(conversation_transcriber_session_stopped_cb)
    conversation_transcriber.canceled.connect(conversation_transcriber_recognition_canceled_cb)
    # stop transcribing on either session stopped or canceled events
    conversation_transcriber.session_stopped.connect(stop_cb)
    conversation_transcriber.canceled.connect(stop_cb)

    conversation_transcriber.start_transcribing_async()

    # Waits for completion.
    overWrite = st.empty()
    while not transcribing_stop:
        time.sleep(.5)
        with overWrite.container():
            st.write(Ofst, "sec処理完了")
        if block != "":
            st.write(block)
            block=""
                
    conversation_transcriber.stop_transcribing_async()
    audio_input = None
    speech_recognizer = None
    return output


#####################################################################################
###############################    MP3文字起こし   ##################################
#####################################################################################
def compressed_stream_helper(output, speech_key, service_region, compressed_format,
        mp3_file_path
        ):
    callback = BinaryFileReaderCallback(mp3_file_path)
    stream = speechsdk.audio.PullAudioInputStream(stream_format=compressed_format, pull_stream_callback=callback)

    speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=service_region)
    if lng == "Japanese":
        speech_config.speech_recognition_language = "ja-JP"
    else:
        speech_config.speech_recognition_language="en-US"
    audio_config = speechsdk.audio.AudioConfig(stream=stream)

    speech_recognizer = speechsdk.SpeechRecognizer(speech_config=speech_config, audio_config=audio_config)

    done = False
    Ofst = 0.0
    block=""
    def recognized(evt):
        nonlocal block
        block += '\r\n[Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        block += evt.result.text+'\r\n'
        nonlocal output
        output+= '\r\n[Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        output+= evt.result.text+'\r\n'
        
    def recognizing(evt):
        #print('RECOGNIZING on {}'.format(evt))
        nonlocal Ofst
        Ofst = evt.result.offset/10000000
    def stop_cb(evt):
        """callback that signals to stop continuous recognition upon receiving an event `evt`"""
        print('STOPPED on {}'.format(evt))
        nonlocal done
        done = True

        

    # Connect callbacks to the events fired by the speech recognizer
    speech_recognizer.recognizing.connect(recognizing)
    speech_recognizer.recognized.connect(recognized)
    speech_recognizer.session_started.connect(lambda evt: print('SESSION STARTED: {}'.format(evt)))
    #speech_recognizer.session_stopped.connect(lambda evt: print('SESSION STOPPED {}'.format(evt)))
    #speech_recognizer.canceled.connect(lambda evt: print('CANCELED {}'.format(evt)))

    # Start continuous speech recognition
    speech_recognizer.start_continuous_recognition()
    # stop continuous recognition on either session stopped or canceled events
    speech_recognizer.session_stopped.connect(stop_cb)
    speech_recognizer.canceled.connect(stop_cb)
    overWrite = st.empty()
    while not done:
        time.sleep(.5)
        
        with overWrite.container():
            st.write(Ofst, "sec処理完了")
        if block != "":
            st.write(block)
            block=""
    speech_recognizer.stop_continuous_recognition()
    return output
    
#####################################################################################
##############    マイクからの文字起こし（つかっていない）   ########################
#####################################################################################
def speech_recognize_once_from_mic():
    # Set up the speech config and audio config
    speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=service_region)
    audio_config = speechsdk.AudioConfig(use_default_microphone=True)

    # Create a speech recognizer with the given settings
    speech_recognizer = speechsdk.SpeechRecognizer(speech_config=speech_config, audio_config=audio_config)

    st.write("Speak into your microphone.")
    result = speech_recognizer.recognize_once_async().get()

    # Check the result
    if result.reason == speechsdk.ResultReason.RecognizedSpeech:
        return f"Recognized: {result.text}"
    elif result.reason == speechsdk.ResultReason.NoMatch:
        return "No speech could be recognized"
    elif result.reason == speechsdk.ResultReason.Canceled:
        cancellation_details = result.cancellation_details
        return f"Speech Recognition canceled: {cancellation_details.reason}"
    else:
        return "Unknown error"

#####################################################################################
################################## WAV文字起こし ####################################
#####################################################################################
def recognize_audio(output, speech_key, service_region, filename, lng, recognize_time=100):
    # Speech to Text 設定
    speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=service_region)
    if lng == "Japanese":
        speech_config.speech_recognition_language = "ja-JP"
    # 入力設定
    audio_input = speechsdk.AudioConfig(filename=filename)
    speech_recognizer = speechsdk.SpeechRecognizer(speech_config=speech_config, audio_config=audio_input)

    done = False
    Ofst = 0.0
    block = ""
    def recognizing(evt):
        #print('RECOGNIZING on {}'.format(evt))
        nonlocal Ofst
        Ofst = evt.result.offset/10000000
    def recognized(evt):
        nonlocal block
        block += '\r\n[Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        block += evt.result.text+'\r\n'
        nonlocal output
        output+= '\r\n[Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        output+= evt.result.text+'\r\n'
        
    def start(evt):
        st.write('SESSION STARTED: {}'.format(evt))
    def stop_cb(evt):
        """callback that stops continuous recognition upon receiving an event `evt`"""
        print('CLOSING on {}'.format(evt))
        speech_recognizer.stop_continuous_recognition()
        nonlocal done
        done = True
        
    # 音声認識の実行
    speech_recognizer.recognizing.connect(recognizing)
    speech_recognizer.recognized.connect(recognized)
    speech_recognizer.session_started.connect(start)
    speech_recognizer.start_continuous_recognition()
     # stop continuous recognition on either session stopped or canceled events
    speech_recognizer.session_stopped.connect(stop_cb)
    speech_recognizer.canceled.connect(stop_cb)

    overWrite = st.empty()
    while not done:
        time.sleep(.5)
        
        with overWrite.container():
            st.write(Ofst, "sec処理完了")
        if block != "":
            st.write(block)
            block=""
        
    audio_input = None
    speech_recognizer = None
    return output

#####################################################################################
############################ WAV文字起こし（話者分離） ##############################
#####################################################################################
def recognize_from_file(output, speech_key, service_region, filename, lng, recognize_time=100):
    # This example requires environment variables named "SPEECH_KEY" and "SPEECH_REGION"
    speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=service_region)
    if lng == "Japanese":
        speech_config.speech_recognition_language = "ja-JP"
    else:
        speech_config.speech_recognition_language="en-US"
    speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_DiarizeIntermediateResults, value='true')

    audio_config = speechsdk.audio.AudioConfig(filename=filename)
    conversation_transcriber = speechsdk.transcription.ConversationTranscriber(speech_config=speech_config, audio_config=audio_config)

    transcribing_stop = False
    Ofst=0.0
    block =""
    def conversation_transcriber_recognition_canceled_cb(evt: speechsdk.SessionEventArgs):
        print('Canceled event')

    def conversation_transcriber_session_stopped_cb(evt: speechsdk.SessionEventArgs):
        print('SessionStopped event')

    def conversation_transcriber_transcribed_cb(evt: speechsdk.SpeechRecognitionEventArgs):
        print('\nTRANSCRIBED:')
        if evt.result.reason == speechsdk.ResultReason.RecognizedSpeech:
            print('\tText={}'.format(evt.result.text))
            print('\tSpeaker ID={}\n'.format(evt.result.speaker_id))
        elif evt.result.reason == speechsdk.ResultReason.NoMatch:
            print('\tNOMATCH: Speech could not be TRANSCRIBED: {}'.format(evt.result.no_match_details))
        nonlocal block
        block += '\r\nSpeaker ID={}'.format(evt.result.speaker_id)+ ' [Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        block += evt.result.text+'\r\n'
        nonlocal output
        output+= '\r\nSpeaker ID={}'.format(evt.result.speaker_id)+ ' [Offset={}sec]'.format(evt.result.offset/10000000)+'\r\n'
        output+= evt.result.text+'\r\n'
        
    def conversation_transcriber_transcribing_cb(evt: speechsdk.SpeechRecognitionEventArgs):
        print('TRANSCRIBING:')
        print('\tText={}'.format(evt.result.text))
        print('\tSpeaker ID={}'.format(evt.result.speaker_id))
        nonlocal Ofst
        Ofst = evt.result.offset/10000000

    def conversation_transcriber_session_started_cb(evt: speechsdk.SessionEventArgs):
        print('SessionStarted event')
    def stop_cb(evt: speechsdk.SessionEventArgs):
        #"""callback that signals to stop continuous recognition upon receiving an event `evt`"""
        print('CLOSING on {}'.format(evt))
        nonlocal transcribing_stop
        transcribing_stop = True

    # Connect callbacks to the events fired by the conversation transcriber
    conversation_transcriber.transcribed.connect(conversation_transcriber_transcribed_cb)
    conversation_transcriber.transcribing.connect(conversation_transcriber_transcribing_cb)
    conversation_transcriber.session_started.connect(conversation_transcriber_session_started_cb)
    conversation_transcriber.session_stopped.connect(conversation_transcriber_session_stopped_cb)
    conversation_transcriber.canceled.connect(conversation_transcriber_recognition_canceled_cb)
    # stop transcribing on either session stopped or canceled events
    conversation_transcriber.session_stopped.connect(stop_cb)
    conversation_transcriber.canceled.connect(stop_cb)

    conversation_transcriber.start_transcribing_async()

    # Waits for completion.
    overWrite = st.empty()
    while not transcribing_stop:
        time.sleep(.5)
        
        with overWrite.container():
            st.write(Ofst, "sec処理完了")
        if block != "":
          st.write(block)
          block=""
    conversation_transcriber.stop_transcribing_async()
    audio_input = None
    speech_recognizer = None
    return output
#####################################################################################
##################################### UI画面 ########################################
#####################################################################################

st.title("Azure Speech + Streamlit")

byte_file = st.file_uploader('こちらからファイルを読み込み')

lng = st.selectbox(
    '言語',
    list(["Japanese","English"])
)
wb = st.selectbox(
    '話者分離',
    list(["True","False"])
)
ft = st.selectbox(
    '処理ファイルタイプ(開発者オプション。Waveは高速だがファイル容量大)',
    list(["Wave","Mp3"])
)

st.divider()
if st.button('Start speech recognition'):
    if byte_file is None:
        st.error("Please upload a file")
    else:
        ## マイク入力する場合(コメントアウト)
        # recognition_result = speech_recognize_once_from_mic()
        # st.write(recognition_result)
        start_time = time.time()
        output_path=""
        if ft == "Wave":
            output_path = "./"+str(datetime.datetime.now().strftime('%Y%m%d%H%M%S%f'))+".wav"
        else:
            output_path = "./"+str(datetime.datetime.now().strftime('%Y%m%d%H%M%S%f'))+".mp3"
        audio = AudioSegment.from_file(byte_file)

        # ファイル情報の表示
        st.subheader("ファイルの内容")
        file_content = {"ファイル名": byte_file.name, "ファイルタイプ": byte_file.type, "長さ(sec)": audio.duration_seconds, "サイズ(Byte)": byte_file.size}
        st.write(file_content)

        st.write("ファイル形式変換開始"+"["+'{:.3f}'.format(time.time()-start_time)+"]")
        convertedaudio=""
        if ft == "Wave":
            convertedaudio = audio.export(output_path, format='wav')
        else:
            convertedaudio = audio.export(output_path, format='mp3')

        st.write("ファイル形式変換完了➡文字起こし開始"+"["+'{:.3f}'.format(time.time()-start_time)+"]")
        st.write("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
        filename = output_path
        output = ""
        if ft == "Wave":
            if wb == "True":
                output = recognize_from_file(output, speech_key, service_region, filename, lng, recognize_time=0)
            else:
                output = recognize_audio(output, speech_key, service_region, filename, lng, recognize_time=0)
        else:
            if wb == "True":
                # Create a compressed format
                compressed_format = speechsdk.audio.AudioStreamFormat(compressed_stream_format=speechsdk.AudioStreamContainerFormat.MP3)
                output = compressed_stream_helper_transcribe(output, speech_key, service_region, compressed_format, output_path)   
            else:
                # Create a compressed format
                compressed_format = speechsdk.audio.AudioStreamFormat(compressed_stream_format=speechsdk.AudioStreamContainerFormat.MP3)
                output = compressed_stream_helper(output, speech_key, service_region, compressed_format, output_path)    
        

        st.write("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")

        st.write("文字起こし完了"+"["+'{:.3f}'.format(time.time()-start_time)+"]")
        st.success("Completed")
        ##st.write(output)
                
        convertedaudio.close()
        os.remove(output_path)
~~~

# アプリの画面
こんな感じのアプリです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1426173/0820c097-93e1-3b25-3ec6-617a3fa1cd7b.png)

# 後日談
会社の環境からWebアプリにアクセスしてみると、画面がロード中のような表示で止まって動かない。調べてみるとStreamlitが使用するWebSocketの通信が失敗していた。会社のプロキシを経由する環境ではWebSocketが動かないようだ。
HTTPS通信とした場合はプロキシが中身が見れないのでプロキシに邪魔されないという情報があったので、急遽、Azure Application Gatewayを作成し、HTTPS通信に対応。無事接続できるようになりました。

## 参考

https://zenn.dev/microsoft/articles/793b43673a7a32