언어·읽기

보이스 리더

한국어 책을 고른 목소리로 기기 안에서 읽어 주는 오프라인 안드로이드 EPUB 리더로, 흑백 e-ink 기기 사용자를 고려했습니다.

분야언어·읽기플랫폼Android (8.0+, arm64)기술Kotlin · Android · Readium Kotlin Toolkit · sherpa-onnx · Supertonic TTS · Media3 · Coroutines
서재·대사 낭독·책장·읽기 옵션.
서재·대사 낭독·책장·읽기 옵션.
낭독 중인 문장에 하이라이트가 따라갑니다.
낭독 중인 문장에 하이라이트가 따라갑니다.
단어를 길게 눌러 발음을 고칩니다(이 책만 / 모든 책).
단어를 길게 눌러 발음을 고칩니다(이 책만 / 모든 책).
목소리 고르기: 기기 음성, Supertonic 음성 팩, 직접 만든 목소리.
목소리 고르기: 기기 음성, Supertonic 음성 팩, 직접 만든 목소리.

왜 만들었나

기기 기본 TTS는 '3개', '요 3:16', 'AI' 같은 한국어 숫자·약어를 어색하게 읽고, 짧은 문장마다 억양이 끊깁니다. 인터넷 없이 자연스러운 음성으로 책을 듣고, 틀린 발음은 직접 고칠 수 있게 했습니다.

주요 기능

  • 기기 안의 EPUB을 복사 없이 찾아 서재·책장으로 정리, DRM 걸린 책은 열지 않고 알림
  • Supertonic 음성 팩(sherpa-onnx)으로 기기 내 합성, 다음 문장 미리 합성으로 끊김 없이 낭독
  • 따옴표로 시작하는 대사만 다른 목소리로 읽기
  • 숫자·날짜·단위·성경 구절·한자 병기를 읽는 말로 바꾸는 한국어 정규화와 책별/전체 발음 사전
  • 애니메이션 없는 흑백 UI, 켬|끔 칸 버튼과 −/+ 버튼 등 e-ink에 맞춘 화면

숫자로 보면

고유어로 읽는 단위어 28개(개·명·살·마리 등)
성경 66권 이름·약어를 인식해 '요 3:16' → '요한복음 삼장 십육절'로 변환
JUnit 단위 테스트 29개

어떻게 동작하나

  1. 책 찾기

    저장소를 walkTopDown으로 훑어 숨김·Android 폴더를 건너뛰고 .epub만 canonicalPath 기준으로 중복 없이 모읍니다.

    app/src/main/java/app/voicereader/DeviceScan.kt
  2. 열기·DRM 검사

    Readium으로 책을 열고 isRestricted와 META-INF/encryption.xml을 확인해 폰트 난독화 외 암호화가 있으면 거절합니다.

    app/src/main/java/app/voicereader/App.kt
  3. 문장 나누기

    Readium 토크나이저로 문장을 자른 뒤 문장 첫머리 대사를 따로 떼고, 40자 미만 문장은 120자 이내로 이어 붙입니다.

    korean/src/main/kotlin/app/voicereader/korean/Dialogue.kt
  4. 읽을 글로 변환

    TTS 엔진을 감싼 래퍼가 문장마다 발음 사전과 KoreanNormalizer를 적용하고, 영어로만 된 문장은 영어로 읽게 합니다.

    app/src/main/java/app/voicereader/TransformingEngine.kt
  5. 미리 합성

    전용 스레드가 대기열의 다음 문장을 미리 합성해 캐시에 두고, 재생할 문장이 준비 안 됐으면 대기열 맨 앞으로 올립니다.

    app/src/main/java/app/voicereader/SupertonicEngine.kt
  6. 재생

    앞뒤 무음을 잘라낸 오디오를 재생하며 Media3 세션으로 백그라운드·잠금화면·이어폰 버튼을 지원합니다.

    app/src/main/java/app/voicereader/PlaybackService.kt

핵심 코드

korean/src/main/kotlin/app/voicereader/korean/KoreanNormalizer.ktGitHub에서 보기 ↗
private fun readNumber(m: MatchResult): String {
    val (prefix, grouped, fraction, unit, counter) = m.destructured
    val raw = grouped.replace(",", "")
    val n = raw.toLongOrNull()
    val number = when {
        n == null -> digits(raw, zero = "영")
        raw.length > 1 && raw.startsWith("0") -> digits(raw, zero = "공")
        counter == "번째" && n == 1L && prefix.isEmpty() -> "첫"
        counter == "월" && n == 6L -> "유"
        counter == "월" && n == 10L -> "시"
        counter in NATIVE_COUNTERS && prefix.isEmpty() && fraction.isEmpty() -> native(n)
        else -> sino(n)
    }
    val point = if (fraction.isEmpty()) "" else " 점 " + digits(fraction.drop(1), zero = "영")
    // …

숫자 뒤에 오는 단위어에 따라 고유어(세 개)·한자어(오 개월)·특수 읽기(유월, 첫)를 고르는 부분입니다.

어려웠던 점

Supertonic이 문장마다 억양과 음높이를 새로 잡아 짧은 문장이 이어지면 다른 사람이 번갈아 읽는 것처럼 들렸습니다. 짧은 문장을 묶되 대사와 서술은 서로 섞이지 않게 나누고, 앞뒤 약 250ms 무음을 잘라 문장 사이 공백을 줄였습니다.