OpenBench AI 데스크톱 앱에 Supertonic TTS를 구현한 방법
요약
Tauri 기반의 로컬 우선 데스크톱 앱인 OpenBench AI에 Supertonic TTS와 브라우저 네이티브 음성 합성 기능을 통합하는 과정을 설명합니다. 개인정보 보호를 위해 API 호출 없이 온디바이스 신경망 모델을 활용한 듀얼 엔진 시스템을 구축하는 기술적 방법을 다룹니다.
핵심 포인트
- Supertonic과 Web Speech API를 활용한 듀얼 TTS 엔진 구현
- Rust 기반의 tauri-plugin-supertonic을 통한 온디바이스 모델 처리
- Zustand를 이용한 TTS 설정 및 재생 상태 관리
- 개인정보 보호를 위한 로컬 우선(Local-first) 아키텍처 적용
최근 저는 로컬 우선(local-first) Tauri 데스크톱 채팅 앱인 OpenBench AI에 텍스트 음성 변환 (TTS, text-to-speech) 기능을 추가했습니다. 단일 TTS 서비스에 의존하는 대신, 사용자가 브라우저 네이티브 음성 합성 (speech synthesis) 또는 고품질 온디바이스 신경망 모델 (on-device neural model) 중 하나를 선택할 수 있는 듀얼 엔진 시스템을 구축했습니다. 그 과정을 소개합니다.
목표
사용자는 어시스턴트의 모든 메시지에 있는 스피커 아이콘을 클릭하여 메시지를 소리 내어 읽을 수 있어야 합니다. 또한 가볍고 내장된 방식 또는 더 자연스럽지만 더 큰 모델 다운로드가 필요한 방식 중 옵션을 선택할 수 있어야 합니다. 모든 프로세싱은 API 호출 없이, 개인정보 보호 문제 없이 로컬에서 이루어져야 합니다.
아키텍처: 듀얼 엔진
저는 단순한 인터페이스를 가진 두 가지 TTS 엔진을 구현했습니다:
Supertonic (ST-TTS) — 온디바이스 신경망 (on-device neural) TTS입니다. 자연스럽고 고품질의 오디오를 생성합니다. 처음 사용 시 약 100MB 크기의 ONNX 모델 다운로드가 필요합니다.
Browser SpeechSynthesis — Web Speech API입니다. 다운로드가 필요 없고 즉시 재생되지만, 자연스러움은 약간 떨어지며 OS에 따라 사용 가능한 음성이 다릅니다.
두 엔진 모두 각 메시지의 스피커 아이콘을 통해 노출되며, 설정(Settings) > 음성(Speech)에서 구성할 수 있습니다.
기술 스택
백엔드: tauri-plugin-supertonic
supertonic-core와 ONNX Runtime을 래핑(wrapping)한 Rust Tauri 2 플러그인(tauri-plugin-supertonic@0.1)입니다.
왜 플러그인인가요? 별도의 커스텀 Rust 명령어가 필요하지 않습니다. 플러그인이 모델 다운로드, 음성 선택, WAV 합성을 포함한 모든 작업을 Rust 레이어에서 처리합니다. 프론트엔드는 JavaScript API를 통해서만 통신합니다.
프론트엔드: tauri-plugin-supertonic-api
깔끔한 JavaScript 인터페이스를 제공하는 npm 패키지(tauri-plugin-supertonic-api@0.1)입니다:
loadModel(): Promise<void>
synthesize(text: string, language: string): Promise<string> // base64 WAV 반환
listVoices(): Promise<Voice[]>
...
상태 관리: 두 개의 Zustand 스토어
settingsStore.ts — TTS 설정을 보유합니다:
type TtsSettings = {
engine: "browser" | "stTts"
browser: {
...
설정은 버전 마이그레이션과 함께 localStorage에 유지됩니다. 따라서 새로운 설정을 추가하더라도 사용자의 기존 설정이 깨지지 않습니다.
ttsStore.ts — 재생 상태를 관리합니다:
type TtsState = {
activeMessageId: string | null
isLoading: boolean
...
메시지별 추적(Per-message tracking)을 통해 각 메시지 버블이 독립적으로 재생/정지/로딩 상태를 표시할 수 있습니다.
엔드 투 엔드(End-to-End) 작동 방식
1. 사용자가 스피커 아이콘을 클릭함
ttsStore.play(messageId, text)를 호출합니다.
2. 텍스트 정제 (Text Cleaning)
cleanTextForSpeech() 함수가 마크다운(Markdown), HTML, 코드 블록(Code blocks), 수학 기호(Math notation)를 제거합니다. TTS 엔진이 **bold**나 $\LaTeX{}$를 발음하려고 시도하는 것을 방지하기 위함입니다.
// 마크다운 굵게/기울임 제거
text = text.replace(/\*\*(.+?)\*\*/g, "$1")
...
```
{% endraw %}
[\s\S]*?
{% raw %}
```/g, "")
text = text.replace(/`(.+?)`/g, "$1")
// HTML 태그 제거
...
3. 엔진 디스패치 (Engine Dispatch)
브라우저 SpeechSynthesis
- 텍스트를 문장 단위로 분리합니다 (
.또는!또는?를 기준으로 한 단순 정규식 분할). - 각 문장에 대해 설정된 음성(Voice), 속도(Rate), 피치(Pitch)를 가진
SpeechSynthesisUtterance를 생성합니다. window.speechSynthesis.speak()를 통해 큐(Queue)에 추가합니다. 브라우저가 자동으로 큐에 쌓습니다.window.speechSynthesis.cancel()을 통해 정지합니다. 장점: 즉각적임, 다운로드 불필요, 어디서나 작동함. 단점: 덜 자연스러움, OS마다 음성 품질이 다름.
Supertonic (ST-TTS)
- 모델이 로드되었는지 확인합니다. 로드되지 않았다면 "TTS 모델 다운로드 중 (~100MB)" 토스트(Toast)를 표시하고
loadModel()을 호출합니다. - 준비가 되면 Supertonic API에서
synthesize(text, "en")를 호출합니다. - base64 형식의 WAV 파일을 반환받습니다.
new Audio("data:audio/wav;base64,...").play()를 통해 재생합니다. 장점: 고품질, 플랫폼 간 일관성, 온디바이스(On-device). 단점: 약 100MB 다운로드 필요, 합성 속도가 느림.
const play = async (messageId: string, text: string) => {
set({ activeMessageId: messageId, isLoading: true })
...
4. 재생 중지
정지(Stop) 함수는 현재의 HTMLAudioElement를 일시 중지하거나 window.speechSynthesis를 취소합니다.
또한, 사용자가 대화창을 전환할 때 자동 정지(Auto-stop)가 실행되어, 오디오가 다음 채팅으로 흘러 들어가는 것을 방지합니다.
설정 UI (SpeechTab.tsx)
설정(Settings) 모달에는 두 개의 섹션으로 구성된 Speech 탭이 있습니다:
엔진 선택기 (Engine Selector)
○ Browser (가볍고 즉각적임)
◉ On-device (자연스러움, 약 100MB 다운로드)
Browser를 선택한 경우
- 음성 드롭다운 (
speechSynthesis.getVoices()에서 가져옴) - 속도 슬라이더 (0.5–2.0)
- 피치 (Pitch) 슬라이더 (0–2.0)
- 음성 테스트 (Test Voice) 버튼
ST-TTS를 선택한 경우
- 모델 로드 (Load Model) 버튼 (진행 상황 표시; 모델이 로딩 중이거나 로드된 경우 비활성화됨)
- 음성 스타일 선택기 (
listVoices()에서 가져옴) - 속도 슬라이더 (지원되는 경우)
- 음성 테스트 (Test Voice) 버튼
두 탭 모두 실시간 재생 미리보기를 제공하여 사용자가 설정을 확정하기 전에 차이점을 들을 수 있습니다.
주요 설계 결정 (Key Design Decisions)
커스텀 Rust 명령 미사용 (No Custom Rust Commands)
플러그인 추상화 덕분에 커스텀 Tauri 명령 핸들러(command handlers)를 작성할 필요가 없었습니다. 플러그인은 깔끔한 npm 패키지를 통해 모든 것을 노출합니다. 향후 새로운 TTS 기능(예: 모델 전환, 스트리밍)을 추가해야 할 경우, 플러그인만 확장하면 되며 프론트엔드는 변경할 필요가 없습니다.
웹 표준 오디오 재생 (Web-Standard Audio Playback)
Tauri 오디오 플러그인을 사용하지 않았습니다. HTMLAudioElement가 supertonic의 WAV 파일을 완벽하게 처리합니다. window.speechSynthesis는 내장되어 있습니다. 두 방식 모두 신뢰할 수 있으며 추가적인 의존성(dependencies)이 전혀 필요하지 않습니다.
메시지별 추적 (Per-Message Tracking)
TTS 스토어(store)의 activeMessageId를 통해 각 메시지 버블이 자신의 재생 상태를 독립적으로 추적할 수 있습니다. 한 메시지가 재생되는 동안 다른 메시지는 "재생" 버튼을 표시할 수 있습니다. 이는 전역 재생/정지(global play/stop) 방식보다 더 자연스럽게 느껴집니다.
지연 모델 로딩 (Lazy Model Loading)
약 100MB 크기의 supertonic 모델은 앱 시작 시가 아니라, 사용자가 ST-TTS를 선택한 상태에서 처음으로 재생을 클릭할 때만 다운로드됩니다. 토스트(toast) 메시지를 통해 현재 진행 상황을 알립니다. 한 번 로드되면 이후의 재생은 즉각적입니다. 이를 통해 기능이 실제로 필요할 때까지 앱을 가볍게 유지할 수 있습니다.
다르게 하거나 다음에 할 일 (What I'd Do Differently (or Next))
- 스트리밍 합성 (Streaming synthesis) — 긴 메시지의 경우, 전체 텍스트를 한 번에 합성하면 느리게 느껴질 수 있습니다. 생성되는 대로 청크(chunk)를 스트리밍하면 체감 성능을 향상할 수 있습니다.
- 음성 캐싱 (Voice caching) — 합성된 오디오를 캐싱하여 동일한 메시지를 다시 재생할 때 즉시 실행되도록 합니다.
- 속도 제한 (Rate limiting) — ST-TTS 합성이 느릴 경우, 요청을 대기열에 추가하거나 "합성 중...(Synthesizing...)" 상태를 더 명확하게 표시합니다.
- 교차 플랫폼 음성 테스트 (Cross-platform voice testing) — 브라우저의 음성 가용성은 플랫폼(Windows, Mac, Linux)마다 크게 다릅니다. 테스트 커버리지가 핵심입니다.
마무리 (Wrapping Up)
듀얼 엔진 TTS는 복잡하지 않지만, 세심한 상태 관리 (state management)와 깔끔한 아키텍처 (architecture)가 필요합니다. 관심사 분리 (separating concerns: 설정, 재생, 엔진 로직)를 수행하고 플러그인 시스템을 활용함으로써, 저는 유연하고 성능이 뛰어나며 유지보수가 용이한 기능을 완성할 수 있었습니다.
만약 Tauri 앱을 구축 중이며 TTS를 추가하고 싶다면, 이 패턴을 그대로 적용할 수 있을 것입니다. 핵심 요점은 엔진을 단순한 인터페이스 (interface) 뒤로 추상화하고, 무거운 리소스는 지연 로딩 (lazy-load)하며, 사용자가 선택할 수 있도록 하는 것입니다.
다음 링크에서 확인해 보세요: https://github.com/monolabsdev/openbench-ai
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기