이 글은 TTS로 일시정지를 구현하면서 겪은 삽질 과정을 정리한 글이다.
TTS 사용법을 알려주는 튜토리얼 같은 글은 아니라는 점 미리 참고 바란다!

 

텍스트를 음성으로 변환해주는 앱을 만들고 있다. 구현할 기능 중 중 하나는 아래와 같이 장문의 글을 음악 플레이어처럼 재생하는 텍스트 플레이어로, 간단한 개요는 아래와 같다.

  • 새로운 파일을 생성하거나, 단말기에 있던 기존 파일을 불러올 수 있다.
  • 파일을 편집하거나 삭제할 수 있다.
  • 파일 내용을 확인하고 재생할 수 있다.
  • 현재 재생 중인 텍스트가 무엇인지 알 수 있도록 글자에 색을 입힌다.
  • 하이라이트 on/off 여부와 색상을 선택할 수 있다.

앱 스크린샷, 디자이너가 따로 없어서 생김새는 좀 별로다

 


예제 소개

스크린샷만 보면 별거 없어 보이나 일시정지 때문에 만드는 과정에서 나름 우여곡절이 있었다.💦

구글링을 했을 때 비슷한 예제가 별로 없기도 하고, 잊어버리지 않도록 기능을 축소시킨 더 단순한 예제로 개발 과정을 정리해보려 한다. 본문에서 만들어볼 예시 앱의 완성본은 아래와 같다.

  • 입력한 텍스트에 대해 재생, 일시정지, 정지 기능을 제공한다.
  • 현재 재생 중인 단어가 무엇인지 표시한다.
  • 화면 밖으로 빠져나가면 일시정지, 다시 돌아오면 일시정지됐던 부분부터 재생한다.

예제 앱 스크린샷

Google TTS

TTS는 Text-To-Speech의 약자로 텍스트를 읽거나 음성 파일로 변환할 수 있도록 해주는 음성 합성 시스템이다. 사용하면 누구나 한 번쯤은 들어봤을 법한 기계음 같은 목소리를 만들어 준다.

Google TTS는 구글에서 안드로이드 OS 용으로 만든 TTS 엔진이다. 안드로이드 운영체제에 기본으로 탑재되어 있고 사용법도 간단하다.

 

Interface

  • SynthesisCallback : TTS 엔진으로 합성된 음성 데이터가 반환될 때 호출되는 콜백
  • TextToSpeech.OnInitListener : TTS 엔진 초기화가 완료되었을 때 호출되는 콜백

Class

  • TextToSpeech : 텍스트를 음성으로 변환해주는 클래스. 사용하려면 반드시 OnInitListener로 초기화를 해줘야 한다.
  • TextToSpeechService : Service를 상속받은 클래스로, TTS 엔진을 구현할 때 필요한 메서드들이 정의된 추상 클래스
  • UtteranceProgressListener : 합성된 음성 데이터 Queue로부터 텍스트 읽기를 처리할 때 사용하는 리스너. TextToSpeech의 대표적인 메서드인 speak()synthesizeToFile()은 비동기로 동작하기 때문에 완료, 오류 발생에 대한 처리를 하고 싶다면 이 리스너를 사용해야 한다.

어떻게 구현할까?

본문을 단순히 읽어주기만 한다면 구현이 매우 간단하겠지만, 일시정지와 하이라이팅을 고려하다보니 조금 더 복잡한 방법이 필요해졌다.
Google TTS는 기본적으로 일시정지 기능을 제공하지 않는다. stop()을 호출하는 순간 해당 문자열에 대한 읽기는 완전히 종료된다. playSilentUtterance(long durationInMs, int queueMode, String utteranceId)를 사용해 재생 대기 상태를 만들 수는 있다. 하지만 이 메서드는 호출할 때부터 인자로 대기 시간을 지정해줘야 한다. 텍스트 플레이어 예제에서는 사용자가 얼마나 오랫동안 일시정지를 유지할지 알 수 없어 다른 방법이 필요했다.

 

텍스트 재생기를 어떻게 만들지 처음엔 크게 두가지 방안을 고안해봤다.

  1. 텍스트 전문을 .mp3 확장자로 변환해 MediaPlayer로 재생한다.
    일시정지를 어떤 시점에 해도 자연스럽게 다시 재생을 시켜줄 수 있다. 그리고 MediaPlayer를 활용함으로써 일시정지 & 재생의 구현이 상대적으로 쉬워진다. 문제는 문자열을 음성 파일로 변환하는 게 내 생각보다 더 무거운 작업이었다는 것이다. synthesizeToFile()를 사용했을 때 신문 기사 정도의 분량만 돼도 체감상 꽤 긴 딜레이가 생겼다. 저장된 글이 많아질수록 mp3 파일이 단말기에서 차지하는 용량이 커지기도 하고, 텍스트가 한글자라도 바뀌면 mp3 파일을 삭제 후 재생성해야 한다는 문제도 있었다.
  2. 텍스트 전문을 . ! ? , 등 특수 기호 단위로 끊어서 차례대로 읽어준다.
    글 전문을 split() 함수로 끊어 배열에 저장한 후, 각 요소에 차례대로 speak() 함수를 적용하는 방법이다. 띄어쓰기나 한 글자 단위로 문자를 짧게 끊으면 다음번 speak() 호출까지의 공백으로 인해 말이 늘어지는 것처럼 들린다. 이 점을 보완하기 위해 문장 단위로 잘라주면 어떨까 싶었다. 보통 문장은 특수 기호로 끝맺음을 하므로 특수 기호를 기준으로 분리했다.
    이 방법의 단점은 일시정지 후 다시 재생을 시작할 때 해당 문장을 처음부터 다시 읽어준다는 것이다. 예를 들어, '저기요. 안녕하세요.'라는 문장이 있을 때 배열은 {"저기요.", "안녕하세요."} 이렇게 만들어진다. 두번째 문장이 재생될 때 '하'에서 일시정지 후 다시 재생을 하면 '세'를 읽을 차례인데, 문장의 시작점인 '안'부터 읽는다. 한번에 재생할 문장이 길다면 듣는 사람 입장에서 일시정지 후 꽤 많은 분량이 되돌아간 느낌이 들 것이다.

다른 앱에선 어떻게 만들었을까?

실제 서비스에서는 이 기능을 어떻게 구현했는지 확인해봤다. 확인 대상은 내가 평소 애용하는 도서 서비스 알라딘과 리디북스. 리디북스는 보니까 Google TTS를 아예 지원을 안 해서 알라딘 이북 앱으로 테스트해봤다.

확인 결과 알라딘에서도 2번 방식처럼 문장 단위로 읽어주기를 구현해놨다.

근데 일시정지를 누르고 다시 시작하면 읽어주기를 최초로 시작한 페이지의 가장 첫 번째 문장으로 되돌아가서 처음부터 읽는다. 인덱스 오류인 것 같다. 알라딘 앱에 있는 다른 TTS를 쓸 땐 일시정지가 정상적으로 동작하나 Google TTS를 골랐을때만 문제가 생긴다.

그리고 몇 번 더 써보다 알았는데 새로운 문단이 시작될 때 사진처럼 줄 바꿈으로 한 줄 띄워져 있으면 줄바꿈 이전으로는 뒤로 가기가 안된다. 보고 따라 하려 했더니만 뜻밖의 버그들만 발굴했다. (-.-);

그리고 삽질...

처음엔 1번 방법을 시도하다가 위에서 언급한 여러 단점에 부딪쳐 2번 방법으로 전향했다. 재생 상태 제어, 인덱스 계산, 문자 분리 과정에서 생길 각종 예외처리 등을 구현하면서 로직이 점점 길고 복잡해지고 있었다. 그리고 하이라이트 위치가 바뀌는 순간과 TTS가 다음 문장을 읽으려는 순간 사이 미묘한 타이밍 차이가 있는건지 문장이 바뀌려는 순간 일시정지를 마구 누르면 가끔 한번씩 인덱스가 엇갈리게 되는 문제가 있었다. 어쩌다 한번씩 재현돼서 디버깅도 어렵고 해결하기 위해 시간을 참 많이 들였다... 어떻게 해야 하나 막막했는데, TTS 문서를 처음부터 다시 읽어보다가 onRangeStart()의 존재를 뒤늦게 알게 되었다. 역시 진리는 공식 문서에 있었다.

 

onRangeStart (String utteranceId, int start, int end, int frame)

UtteranceProgressListener에 정의된 메서드 중 하나로, TTS가 문장에서 특정한 범위를 읽어줄 때 호출되는 콜백이다. startend로 해당 범위의 index를 알 수 있다.

 

This is called when the TTS service is about to speak the specified range of the utterance with the given utteranceId. ...(중략)... This information can be used, for example, to highlight ranges of the text while it is spoken.

 

메서드 설명부터 highlight가 이미 예시로 들어가 있다. onRangeStart()를 적용하니 인덱스 처리나 각종 예외 처리가 필요 없어지면서 코드가 훨씬 간단해졌다. 또 단어 단위로 인덱스를 관리하고, speak를 연속적으로 호출할 필요가 사라졌기에 문장 단위로 읽어주기를 할 때보다 일시정지 후 재생이 더 자연스러워졌다. 메서드 하나만으로 2번 방안이 한층 더 업그레이드 된 것이다.

onRangeStart()는 API 26에서 추가된 메서드라, 구글링을 했을 때 오래된 글 위주로 상단에 노출되는 Stackoverflow 같은 곳에선 나오지 않았다. 처음부터 문서를 꼼꼼하게 정독했으면 쉽게 해결했을 것을 길게 돌아온 느낌이었다.

 

소스 코드

xml 레이아웃은 생략한다. 소스코드 전문은 링크에서 확인 가능하다.

 

1. PlayState

플레이어의 재생 상태를 나타내기 위한 enum 클래스다. 상태는 크게 재생, 일시정지, 정지로 구분된다. 굳이 enum을 쓰지 않아도 구현은 가능하지만 state에 엉뚱한 값이 할당되는 일을 방지하기 위해 타입을 따로 만들었다.

public enum PlayState {
    PLAY("재생 중"), WAIT("일시정지"), STOP("멈춤");

    private String state;

    PlayState(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }

    public boolean isStopping() {
        return this == STOP;
    }

    public boolean isWaiting() {
        return this == WAIT;
    }

    public boolean isPlaying() {
        return this == PLAY;
    }
}

2. TextPlayer

텍스트 플레이어의 규격을 정의해둔 인터페이스다.

public interface TextPlayer {

    void startPlay();

    void pausePlay();

    void stopPlay();
}

3. MainActivity

  • TTS를 사용하기 위해 TextToSpeech 객체를 초기화해야 한다.
  • 초기화에 성공했다면 기본 언어를 한국어로 설정한다. 목소리톤, 재생 속도 등 음성에 대한 각종 설정을 이 메서드 안에서 처리할 수 있다.
  • onRangeStart() 안에서는 현재 음성이 출력되는 범위에 맞춰 하이라이트 위치를 바꾸고 마지막으로 재생된 단어의 index를 저장한다.
  • 재생이 끝나면 onDone이 호출된다. clearAll()로 모든 상태를 초기화한다.
    private void initTTS() {
        params.putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, null);

        tts = new TextToSpeech(this, new TextToSpeech.OnInitListener() {
            @Override
            public void onInit(int state) {
                if (state == TextToSpeech.SUCCESS) {
                    tts.setLanguage(Locale.KOREAN);
                } else {
                    showState("TTS 객체 초기화 중 에러가 발생했습니다.");
                }
            }
        });
        tts.setOnUtteranceProgressListener(new UtteranceProgressListener() {
            @Override
            public void onStart(String s) {

            }

            @Override
            public void onDone(String s) {
                clearAll();
            }

            @Override
            public void onError(String s) {
                showState("재생 중 에러가 발생했습니다.");
            }

            @Override
            public void onRangeStart(String utteranceId, int start, int end, int frame) {
                changeHighlight(standbyIndex + start, standbyIndex + end);
                lastPlayIndex = start;
            }
        });
    }
  • 재생, 일시정지, 정지를 구현했다. 새롭게 재생을 시작했을 땐 EditText의 내용을 가져와 처음부터 읽기를 진행하고, 일시정지 후 다시 시작할 경우 마지막으로 재생했던 위치를 기준으로 읽기를 시작한다.
  • 일시정지가 발생한 이후로는 TTS에서 재생해야 할 텍스트TextView에 표시할 텍스트가 서로 달라진다. 따라서 standbyIndex와 lastPlayIndex로 두 가지 텍스트의 위치를 구분한다.
  • 이미 재생 중일 때 또 재생 버튼을 누르면 별다른 처리가 필요 없으므로 분기를 만들지 않았다.
    @Override
    public void onClick(View view) {
        switch (view.getId()) {
            case R.id.btn_play:
                startPlay();
                break;

            case R.id.btn_pause:
                pausePlay();
                break;

            case R.id.btn_stop:
                stopPlay();
                break;
        }
        showState(playState.getState());
    }

    @Override
    public void startPlay() {
        String content = inputEditText.getText().toString();
        if (playState.isStopping() && !tts.isSpeaking()) {
            setContentFromEditText(content);
            startSpeak(content);
        } else if (playState.isWaiting()) {
            standbyIndex += lastPlayIndex;
            startSpeak(content.substring(standbyIndex));
        }
        playState = PlayState.PLAY;
    }

    @Override
    public void pausePlay() {
        if (playState.isPlaying()) {
            playState = PlayState.WAIT;
            tts.stop();
        }
    }

    @Override
    public void stopPlay() {
        tts.stop();
        clearAll();
    }
  • TextView에 배경색을 입혀주는 메서드다. speak()가 async 하게 동작하기 때문에 리스너 내부에서 바로 UI에 접근하면 안 된다. 백그라운드 스레드에서 UI 관련 처리를 하기 위해 runOnUiThread()를 사용한다.
    private void changeHighlight(final int start, final int end) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                spannable.setSpan(colorSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        });
    }
  • 텍스트 변경, 플레이어 초기화, Toast 보여주기 등을 수행하는 메서드들이다.
    private void setContentFromEditText(String content) {
        contentTextView.setText(content, TextView.BufferType.SPANNABLE);
        spannable = (SpannableString) contentTextView.getText();
    }

    private void startSpeak(final String text) {
        tts.speak(text, TextToSpeech.QUEUE_ADD, params, text);
    }

    private void clearAll() {
        playState = PlayState.STOP;
        standbyIndex = 0;
        lastPlayIndex = 0;

        if (spannable != null) {
            changeHighlight(0, 0); // remove highlight
        }
    }

    private void showState(final String msg) {
        Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();
    }
  • 액티비티 생명주기에 맞춰 적절한 처리가 필요하다.
  • onCreate에서 view와 tts의 초기화 작업을 수행한다.
  • 사용자가 화면에서 이탈했을 경우(onPause) 재생을 일시 정지하고, 다시 복귀했을 경우(onResume) 멈췄던 부분에서 재생을 재시작한다.
  • 액티비티를 종료할 때(onDestory) TTS 객체 사용을 종료해야 한다.
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initTTS();
        initView();
    }

    private void initView() {
        playBtn = findViewById(R.id.btn_play);
        pauseBtn = findViewById(R.id.btn_pause);
        stopBtn = findViewById(R.id.btn_stop);
        inputEditText = findViewById(R.id.et_input);
        contentTextView = findViewById(R.id.tv_content);

        playBtn.setOnClickListener(this);
        pauseBtn.setOnClickListener(this);
        stopBtn.setOnClickListener(this);
    }

    @Override
    protected void onPause() {
        if (playState.isPlaying()) {
            pausePlay();
        }
        super.onPause();
    }

    @Override
    protected void onResume() {
        if (playState.isWaiting()) {
            startPlay();
        }
        super.onResume();
    }

    @Override
    protected void onDestroy() {
        tts.stop();
        tts.shutdown();
        super.onDestroy();
    }

마지막으로 액티비티 전체 소스코드를 첨부한다.

public class MainActivity extends AppCompatActivity implements TextPlayer, View.OnClickListener {
    private final Bundle params = new Bundle();
    private final BackgroundColorSpan colorSpan = new BackgroundColorSpan(Color.YELLOW);
    private TextToSpeech tts;
    private Button playBtn;
    private Button pauseBtn;
    private Button stopBtn;
    private EditText inputEditText;
    private TextView contentTextView;
    private PlayState playState = PlayState.STOP;
    private Spannable spannable;
    private int standbyIndex = 0;
    private int lastPlayIndex = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initTTS();
        initView();
    }

    private void initView() {
        playBtn = findViewById(R.id.btn_play);
        pauseBtn = findViewById(R.id.btn_pause);
        stopBtn = findViewById(R.id.btn_stop);
        inputEditText = findViewById(R.id.et_input);
        contentTextView = findViewById(R.id.tv_content);

        playBtn.setOnClickListener(this);
        pauseBtn.setOnClickListener(this);
        stopBtn.setOnClickListener(this);
    }

    private void initTTS() {
        params.putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, null);
        tts = new TextToSpeech(this, new TextToSpeech.OnInitListener() {
            @Override
            public void onInit(int state) {
                if (state == TextToSpeech.SUCCESS) {
                    tts.setLanguage(Locale.KOREAN);
                } else {
                    showState("TTS 객체 초기화 중 문제가 발생했습니다.");
                }
            }
        });

        tts.setOnUtteranceProgressListener(new UtteranceProgressListener() {
            @Override
            public void onStart(String s) {

            }

            @Override
            public void onDone(String s) {
                clearAll();
            }

            @Override
            public void onError(String s) {
                showState("재생 중 에러가 발생했습니다.");
            }

            @Override
            public void onRangeStart(String utteranceId, int start, int end, int frame) {
                changeHighlight(standbyIndex + start, standbyIndex + end);
                lastPlayIndex = start;
            }
        });
    }

    @Override
    public void onClick(View view) {
        switch (view.getId()) {
            case R.id.btn_play:
                startPlay();
                break;

            case R.id.btn_pause:
                pausePlay();
                break;

            case R.id.btn_stop:
                stopPlay();
                break;
        }
        showState(playState.getState());
    }

    @Override
    public void startPlay() {
        String content = inputEditText.getText().toString();
        if (playState.isStopping() && !tts.isSpeaking()) {
            setContentFromEditText(content);
            startSpeak(content);
        } else if (playState.isWaiting()) {
            standbyIndex += lastPlayIndex;
            startSpeak(content.substring(standbyIndex));
        }
        playState = PlayState.PLAY;
    }

    @Override
    public void pausePlay() {
        if (playState.isPlaying()) {
            playState = PlayState.WAIT;
            tts.stop();
        }
    }

    @Override
    public void stopPlay() {
        tts.stop();
        clearAll();
    }

    private void changeHighlight(final int start, final int end) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                spannable.setSpan(colorSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        });
    }

    private void setContentFromEditText(String content) {
        contentTextView.setText(content, TextView.BufferType.SPANNABLE);
        spannable = (SpannableString) contentTextView.getText();
    }

    private void startSpeak(final String text) {
        tts.speak(text, TextToSpeech.QUEUE_ADD, params, text);
    }

    private void clearAll() {
        playState = PlayState.STOP;
        standbyIndex = 0;
        lastPlayIndex = 0;

        if (spannable != null) {
            changeHighlight(0, 0); // remove highlight
        }
    }

    private void showState(final String msg) {
        Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();
    }

    @Override
    protected void onPause() {
        if (playState.isPlaying()) {
            pausePlay();
        }
        super.onPause();
    }

    @Override
    protected void onResume() {
        if (playState.isWaiting()) {
            startPlay();
        }
        super.onResume();
    }

    @Override
    protected void onDestroy() {
        tts.stop();
        tts.shutdown();
        super.onDestroy();
    }
}

 


참고 사항

테스트를 하다가 우연히 알게 된 사실을 덧붙인다. Samsung TTS에서는 onRangeStart()가 사용 불가능하다.

오버라이딩 했다고 앱이 강제 종료되거나 하진 않는데 그냥 호출이 안된다. Google TTS에서만 사용 가능한듯. getDefaultEngine()로 현재 사용자가 설정해둔 목소리 엔진을 확인해 적절한 처리를 해줘야 할 듯하다.

 

참고 자료