반응형

문서를 듣자!! 문서를 읽는 것은 집중력을 높여 이해도를 높여 주기도 하지만, 많은 문서를 볼 때는 오히려 음성으로 듣는 것이 낫기도 하다. 여기서는 Google Cloud TTS를 이용하여 Google Docs에 있는 내용을 음성으로 들어 본다. Google Cloud TTS의 예제이자, Google Docs 문서를 액세스하는 예제이기도 하다.

 

Permission

 

Service Account 생성 및 Key 생성 및 다운로드

  • tts-api라는 이름의 Service Account 생성 및 별 다른 Permission을 주지 않음

    • 생성된 Account: tts-api@api-project-249965614499.iam.gserviceaccount.com

  • 아래와 같이 JSON Key 생성

 

  • 다음 받은 JSON 파일을 현재 디렉토리로 복사

$ mv ~/Downloads/api-project-249965614499-33c4f9f41469.json .



액세스할 Google Docs 또는 Google Drive 폴더에 Service Account의 Read 권한 추가



필요한 구글 Python 라이브러리 패키지 설치

pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib
pip install oauth2client
pip install google-cloud
pip install argparse



Google Docs를 읽어 JSON으로 출력하는 프로그램 작성

  • 일단 테스트로 Google Docs의 JSON 구조를 확인하 위한 프로그램

  • dic.json 파일 생성

  • DOCUMENT_ID에 Google Docs의 ID (참고로 본 문서 사용)

from googleapiclient import discovery
from google.oauth2 import service_account
import json
import os

DOCUMENT_ID='1FCGcY00_8_DK8edLP1ws5NQamdmB6agCZpfXXXXXXXXXXXXX'

SCOPES = [
#    'https://www.googleapis.com/auth/drive',
    'https://www.googleapis.com/auth/documents.readonly'
]
# DISCOVERY_DOC = 'https://docs.googleapis.com/$discovery/rest?version=v1'
DISCOVERY_DOC = 'https://docs.googleapis.com/$discovery/rest?version=v1'

SERVICE_ACCOUNT_FILE = 'api-project-249965614499-33c4f9f41469.json'
credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_FILE, scopes=SCOPES)

service = discovery.build('docs', 'v1', discoveryServiceUrl=DISCOVERY_DOC, credentials=credentials)

# Do a document "get" request and print the results as formatted JSON
f = open("dic.json", "w")
result = service.documents().get(documentId=DOCUMENT_ID).execute()
jsonStr = json.dumps(result, indent=4, sort_keys=True)
f.write(jsonStr)
f.close()

print("dic.json has been created")

 

생성된 JSON 파일의 구조 확인

  • body/content 배열에 있는 paragraph가 각각의 문단

  • content에 텍스트 내용이 들어가고 한글은 Unicode로 escaping 됨

  • namedStyleType에 테스트의 Style

{
    "body": {
        "content": [
            {
                "endIndex": 1,
                "sectionBreak": {
                    "sectionStyle": {
                        "columnSeparatorStyle": "NONE",
                        "contentDirection": "LEFT_TO_RIGHT",
                        "sectionType": "CONTINUOUS"
                    }
                }
            },
            {
                "endIndex": 38,
                "paragraph": {
                    "elements": [
                        {
                            "endIndex": 38,
                            "startIndex": 1,
                            "textRun": {
                                "content": "Google Cloud TTS to read Google Docs\n",
                                "textStyle": {}
                            }
                        }
                    ],
                    "paragraphStyle": {
                        "direction": "LEFT_TO_RIGHT",
                        "headingId": "h.swe0qm2wob2",
                        "namedStyleType": "TITLE"
                    }
                },
                "startIndex": 1
            },
...
            {
                "endIndex": 42,
                "paragraph": {
                    "elements": [
                        {
                            "endIndex": 42,
                            "startIndex": 39,
                            "textRun": {
                                "content": "\ucc38\uace0\n",
                                "textStyle": {}
                            }
                        }
                    ],
                    "paragraphStyle": {
                        "direction": "LEFT_TO_RIGHT",
                        "headingId": "h.1o1ly2f6sob",
                        "namedStyleType": "HEADING_2"
                    }
                },
                "startIndex": 39
            },

 

JSON에서 문단을 추출하는 프로그램

  • dic.json을 참고해서 다음과 같이 문단 추출 부분을 작성

...


service = discovery.build('docs', 'v1', discoveryServiceUrl=DISCOVERY_DOC, credentials=credentials)


result = service.documents().get(documentId=DOCUMENT_ID).execute()

for sentence in result['body']['content']:
if 'paragraph' in sentence and 'textRun' in sentence['paragraph']['elements'][0]:
print(sentence['endIndex'])
# print("**************************************************************************")
# print(json.dumps(sentence['paragraph'], indent=4, sort_keys=True))
text = sentence['paragraph']['elements'][0]['textRun']['content']
style = sentence['paragraph']['paragraphStyle']['namedStyleType']
print(style, text)

 

  • 테이블 처리를 추가

...


for sentence in result['body']['content']:
if 'paragraph' in sentence and 'textRun' in sentence['paragraph']['elements'][0]:
print(sentence['endIndex'])
# print("**************************************************************************")
# print(json.dumps(sentence['paragraph'], indent=4, sort_keys=True))
text = sentence['paragraph']['elements'][0]['textRun']['content']
style = sentence['paragraph']['paragraphStyle']['namedStyleType']
print(style, text)
elif 'table' in sentence:
print(sentence['endIndex'])
for tableRow in sentence['table']['tableRows']:
for tableCell in tableRow['tableCells']:
text = ''
for tableContents in tableCell['content']:
print(tableContents['endIndex'])
for tableSentence in tableContents['paragraph']['elements']:
text = text + tableSentence['textRun']['content']
text = text + '\u000b'
for line in text.split('\u000b'):
print('[TABLE]', line)

 

  • 테이블 테스트

컬럼1

컬럼2

컬럼3

로우2-1

로우2-2

로우2-3

로구3-1

로우3-2

로우3-3

문장인 경우에는 어떻게 하는 지 궁금합니다. 이어진 문장.

떨어진 문장.

   




Flask Python 프로그램 작성

  • 일단 Flask를 통해서 화면에 시각화 하는 프로그램. Google Docs 문서 ID를 URL 파라미터로 보내면 해당 결과를 HTML로 렌더링 해 준다

from flask import Flask, request, Response
from googleapiclient import discovery
from google.oauth2 import service_account
import json
import os

app = Flask(__name__)

@app.route("/<docid>")
def hello(docid):
styles = {
'TITLE': '<h1>%s%s</h1>',
'HEADING_1': '<h2>%s%s</h2>',
'HEADING_2': '<h3>%s%s</h3>',
'HEADING_3': '<h4>%s%s</h4>',
'NORMAL_TEXT': '<p>%s%s</p>'
}

DOCUMENT_ID=docid

SCOPES = [
#    'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/documents.readonly'
]
# DISCOVERY_DOC = 'https://docs.googleapis.com/$discovery/rest?version=v1'
DISCOVERY_DOC = 'https://docs.googleapis.com/$discovery/rest?version=v1'

SERVICE_ACCOUNT_FILE = 'api-project-249965614499-33c4f9f41469.json'
credentials = service_account.Credentials.from_service_account_file(
SERVICE_ACCOUNT_FILE, scopes=SCOPES)

service = discovery.build('docs', 'v1', discoveryServiceUrl=DISCOVERY_DOC, credentials=credentials)

result = service.documents().get(documentId=DOCUMENT_ID).execute()

f = open("dic.json", "w")
jsonStr = json.dumps(result, indent=4, sort_keys=True)
f.write(jsonStr)
f.close()

html = ''
for sentence in result['body']['content']:
if 'paragraph' in sentence and 'textRun' in sentence['paragraph']['elements'][0]:
print(sentence['endIndex'])
# print("**************************************************************************")
# print(json.dumps(sentence['paragraph'], indent=4, sort_keys=True))
bullet = ''
if 'bullet' in sentence['paragraph']:
bullet = '&#8226;'
text = ''
for t in sentence['paragraph']['elements']:
text = text + t['textRun']['content']
style = sentence['paragraph']['paragraphStyle']['namedStyleType']
print(style, text)
html = html + (styles[style] % (bullet, text)) + "\n"
elif 'table' in sentence:
print(sentence['endIndex'])
html = html + "<table border='1'>\n"
for tableRow in sentence['table']['tableRows']:
html = html + "<tr>\n"
for tableCell in tableRow['tableCells']:
html = html + "<td>\n"
text = ''
for tableContents in tableCell['content']:
print(tableContents['endIndex'])
for tableSentence in tableContents['paragraph']['elements']:
text = text + tableSentence['textRun']['content']
text = text + '\u000b'
for line in text.split('\u000b'):
print('[TABLE]', line)
html = html + ("<p>%s</p>\n" % (line))
html = html + "</td>\n"
html = html + "</tr>\n"
return html, 200

if __name__ == "__main__":
app.run(host='0.0.0.0', port=5001, debug=False)

 

  • 나중에 JavaScript에서 제어를 편하게 하기 위해서는 <h1> 등의 태그를 직접 사용하지 말고 <div>에 고유한 class 명을 넣어서 제어

styles = {
'TITLE': '<div class="jerry_tts"><h1>%s%s</h1></div>',
'HEADING_1': '<div class="jerry_tts"><h2>%s%s</h2></div>',
'HEADING_2': '<div class="jerry_tts"><h3>%s%s</h3></div>',
'HEADING_3': '<div class="jerry_tts"><h4>%s%s</h4></div>',
'NORMAL_TEXT': '<div class="jerry_tts"><p>%s%s</p></div>'
}



Text To Speech API를 호출해서 Text를 읽기

  • Text를 음성으로 만들어 출력하는 부분은 브라우저에서 이루어져야 함

  • Google Cloud Text To Speech API 설치

$ pip install google-cloud-texttospeech

 

  • Text를 보내면 TTS API 호출

@app.route("/speak/<encoding>")
def speak(encoding):
text = request.args.get('text', '')

# Instantiates a client
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = SERVICE_ACCOUNT_FILE
client = texttospeech.TextToSpeechClient()

# Set the text input to be synthesized
synthesis_input = texttospeech.SynthesisInput(text=text)

# Build the voice request, select the language code ("en-US") and the ssml
# voice gender ("neutral")
voice = texttospeech.VoiceSelectionParams(
language_code=encoding, ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL
)

# Select the type of audio file you want returned
audio_config = texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3
)

# Perform the text-to-speech request on the text input with the selected
# voice parameters and audio file type
response = client.synthesize_speech(
input=synthesis_input, voice=voice, audio_config=audio_config
)

# The response's audio_content is binary.
with open("output.mp3", "wb") as out:
# Write the response to the output file.
out.write(response.audio_content)

return Response(response=response.audio_content, mimetype='audio/mp3')

 

JavaScript 에서 음성 읽기를 키보드로 제어

  • Ctrl+> 키나 Ctrl+< 키를 통해 전진, 후진

  • Ctrl+? 키로 음성 읽기 중단

scripts = '''
<script>
idx = -1;
lastIdx = -1;
var audio = null;

window.onload = function() {
document.addEventListener('keydown', keyEvent);
setTimeout(forward, 200);
}

function play() {
sentences = document.querySelectorAll(".jerry_tts");
sentence = sentences[idx];
if(sentence.nodeName == "DIV" && sentence.textContent) {
if(lastIdx >= 0) {
sentences[lastIdx].style.backgroundColor='transparent';
}
sentence.style.backgroundColor='yellow';
lastIdx = idx;
sentence.scrollIntoView(true);
window.scrollTo(0, window.scrollY - 100);
encoding = document.querySelector("body > select:nth-child(1)").value
speed = document.querySelector("body > select:nth-child(2)").value
if(audio) {
audio.pause();
}
audio = new Audio("/speak/" + encoding + "?text=" + sentence.textContent);
audio.load();
audio.playbackRate = speed;
audio.play()
.then(() => {
// Playing
})
.catch(error => {
console.log("empty voice");
setTimeout(forward, 100);
});
audio.addEventListener('ended', forward);
}
}

function forward() {
sentences = document.querySelectorAll(".jerry_tts");
if(idx - 1 < sentences.length) {
idx++;
}

play();
}

function backward() {
if(idx > 0) {
idx--;
}

play();
}

function pause() {
if(audio) {
if(audio.paused) {
audio.play();
}
else {
audio.pause();
}
}
}

function keyEvent(e) {
    e = e || window.event;
if(e.ctrlKey && e.key == '.') {
forward();
}
else if(e.ctrlKey && e.key == ',') {
backward();
}
else if(e.ctrlKey && e.key == '/') {
pause();
}
}
</script>
'''



HTML Body 생성

  • 인코딩, 재생 속도 등을 제어할 수 있는 컨트롤 배치

  html = '<html>\n'
  html = html + scripts
  html = html + '''
      <body>
      <select name='encoidng' onChange='play()'>
          <option value='en-US'>en-US</option>
          <option value='ko-KR'>ko-KR</option>
      </select>
      <select name='speed' onChange='play()'>
          <option value='2'>2배</option>
          <option value='1.5'>1.5배</option>
          <option value='1.3'>1.3배</option>
          <option value='1.2'>1.2배</option>
          <option value='1.1'>1.1배</option>
          <option value='1' selected='selected'>1배</option>
          <option value='0.9'>0.9배</option>
          <option value='0.8'>0.8배</option>
          <option value='0.7'>0.7배</option>
          <option value='0.5'>0.5배</option>
      </select>
      <input type='button' value='Pause/Resume' onClick='pause()'>
      <div class='jerry_tts_body'>
  '''

 



최종 소스



Cloud Run으로 실행

  • 다음과 같이 스크립트를 만들어 실행

  • 먼저 로컬에서 다음과 같은 스크립트를 이용하여 테스트 

  • Dockerfile (5001 포트를 사용)

$ cat Dockerfile
FROM python:3.7-slim

# Copy local code to the container image.
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./

# Install production dependencies.
RUN pip install Flask gunicorn
RUN pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib
RUN pip install oauth2client
RUN pip install google-cloud
RUN pip install google-cloud-texttospeech
RUN pip install argparse

EXPOSE 5001

# Run the web service on container startup. Here we use the gunicorn
# webserver, with one worker process and 8 threads.
# For environments with multiple CPU cores, increase the number of workers
# to be equal to the cores available.
CMD exec gunicorn --bind :5001 --workers 1 --threads 8 --timeout 0 tts-read-google-docs5:app



$ cat 02.test_local.sh
if [ "$1" == "build" ];
then

mkdir -p build
cd build
cp ../tts-read-google-docs5.py .
cp ../api-project-249965614499-33c4f9f41469.json .
cp ../Dockerfile .
docker build -t "google-docs-tts" .

fi

docker run --name jerry -d -p 5001:5001 google-docs-tts

sleep 1
docker logs -f jerry &

sleep 1
echo Enter to stop...
read a

docker stop jerry
docker rm jerry

 

  • 다음과 같이 Cloud Build 실행 (프로젝트 ID 등은 각자)

$ cat 01.build.sh
mkdir -p build
cd build
cp ../tts-read-google-docs5.py .
cp ../api-project-249965614499-33c4f9f41469.json .
cp ../Dockerfile .
cp ../favicon.ico .
gcloud builds submit --project api-project-249965614499 --tag gcr.io/api-project-249965614499/google-docs-tts

 

  • 다음과 같이 Cloud Run으로 실행

$ cat 03.deploy.sh
gcloud run deploy --project api-project-249965614499 --image gcr.io/api-project-249965614499/google-docs-tts --platform managed --region asia-northeast1 --port 5001 --allow-unauthenticated google-docs-tts














Posted by Hey Jerry
,