7월 7일 이후의 방향 변화
7월 7일에 SoriShelf/말서랍의 진행 상황을 한 번 정리했다.
그때 기준으로는 앱의 큰 방향이 잡힌 상태였다. 사용자가 말을 녹음하고, 녹음 내용을 텍스트로 남긴 뒤, 회의록 형태의 문서로 정리하는 Android Flutter 앱. 개인정보를 우선하기 위해 외부 서버 업로드를 기본 흐름으로 보지 않고, 로컬 저장과 기기 안에서 가능한 처리를 중심에 두는 방향이었다.
7월 12일 기준으로는 그중에서도 음성 → 텍스트 변환 부분을 더 깊게 파고들었다.
처음에는 Android SpeechRecognizer를 녹음 중 함께 실행해서, 사용자가 말하는 동안 받아쓴 텍스트를 저장하는 방식이었다. 이 방식은 구현이 가볍고, 기기 기본 기능을 사용한다는 장점이 있었다. 하지만 실제 앱 흐름을 생각해보면 아쉬운 점이 있었다.
녹음 앱에서 사용자가 기대하는 것은 보통 이것이다.
- 일단 녹음한다.
- 녹음 파일이 안전하게 저장된다.
- 나중에 상세 화면에서 음성을 글로 바꾼다.
- 그 텍스트를 바탕으로 회의록을 만든다.
녹음 중 받아쓰기 방식은 2번과 3번 사이가 약했다. 녹음 중 음성 인식이 끊기면 나중에 저장된 음성 파일로 다시 텍스트를 만들기 어렵다. 사용자가 “녹음 파일은 있는데 왜 다시 글로 못 바꾸지?”라고 느낄 수 있다. 그래서 7월 7일 이후에는 저장된 음성 파일을 후처리해서 텍스트로 바꾸는 방향을 실험하기 시작했다.
이 과정에서 선택한 것이 sherpa-onnx였다.
왜 음성 파일 전사가 필요했나
말서랍은 단순한 실시간 받아쓰기 앱이 아니다.
제품의 중심은 녹음이 아니라, 녹음 후 만들어지는 기록이다. 사용자가 회의나 인터뷰를 녹음했다면, 그 파일이 나중에 다시 처리될 수 있어야 한다. 앱이 잠깐 백그라운드로 가거나, 음성 인식이 중간에 끊기거나, 모델 다운로드가 나중에 완료되더라도, 저장된 원본 음성을 기반으로 다시 녹음 내용을 만들 수 있어야 한다.
그래서 음성 → 텍스트 변환은 말서랍에서 단순 부가 기능이 아니라 핵심 파이프라인이다.
기존 Android SpeechRecognizer 방식에는 몇 가지 한계가 있었다.
- 녹음 중 인식 결과를 받지 못하면 사후 복구가 어렵다.
- 기기와 OS, Google 음성 인식 상태에 따라 품질과 안정성이 달라진다.
- 긴 녹음에서 끊김과 재시작 처리가 복잡하다.
- 저장된 오디오 파일을 사용자가 직접 다시 전사하는 흐름과 맞지 않는다.
- 개인정보를 지키면서도 반복 가능한 전사 품질을 확보하기 어렵다.
그래서 다음 방향은 명확했다.
녹음 파일을 저장하고, 그 파일을 앱 안에서 오프라인으로 읽어 텍스트로 바꾼다.
이 방식이면 네트워크를 기본 전제로 두지 않아도 되고, 사용자가 민감한 음성을 외부 API에 올리지 않아도 된다. 또한 전사 모델이 준비되지 않은 상태에서 녹음한 파일도, 모델을 설치한 뒤 다시 처리할 수 있는 구조로 갈 수 있다.
sherpa-onnx를 준비한 이유
음성 파일 전사를 위해 sherpa_onnx 패키지를 추가했다.
현재 앱은 SherpaOnnxTranscriptionService를 통해 저장된 WAV 파일을 읽고, sherpa-onnx의 offline recognizer로 텍스트를 만든다. 한국어와 영어를 모두 고려하되, 현재 한국어는 SenseVoice 모델을 기준으로 잡았다.
문서에는 sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17 계열 모델을 기준으로 적어두었다. 이유는 한국어를 포함한 다국어 모델이고, int8 모델이라 모바일에서 실험하기에 상대적으로 부담이 낮기 때문이다.
앱이 기대하는 모델 파일은 단순하다.
model.onnxtokens.txt
처음부터 앱 번들에 모델을 넣지 않고, GitHub Releases에서 내려받는 방식도 같이 준비했다. 모델 파일은 크기가 크고, 앱을 설치하는 모든 사용자에게 처음부터 포함시키기에는 부담이 있다. 그래서 상세 화면에서 모델이 없을 때 음성 인식 모델 다운로드 버튼을 보여주고, 사용자가 필요할 때 내려받게 하는 방향으로 잡았다.
기본 다운로드 URL은 다음 형태다.
https://github.com/moagalstudio/sorishelf-models/releases/download/sherpa-sense-voice-int8-2024-07-17
GitHub Release에는 다음 파일을 올리는 것으로 정리했다.
model.onnxtokens.txtmanifest.jsonSHA256SUMS
상류 모델 파일명이 model.int8.onnx인 경우에도 앱에서는 model.onnx라는 이름을 기대하도록 했다. 앱 코드와 배포 파일명이 어긋나면 사용자는 모델을 내려받아도 계속 “모델 파일 없음” 상태를 보게 된다. 그래서 앱 쪽 기대 이름을 단순하게 고정했다.
녹음 포맷을 바꾼 이유
7월 7일 기준 녹음 파일은 .m4a였다.
하지만 sherpa-onnx 파일 전사를 붙이면서 녹음 포맷을 .wav로 바꿨다. 현재 RecordingService는 다음 설정으로 녹음한다.
- encoder: WAV
- sample rate: 16000
- channel: mono
- bit rate: 256000
이 변경은 품질 튜닝보다 호환성 때문에 필요했다. 현재 전사 구현은 sherpa_onnx.readWave()로 파일을 읽는다. 즉, 앱이 바로 읽을 수 있는 WAV 파일이어야 한다. 기존 .m4a 파일은 이 구현으로 전사할 수 없다.
그래서 문서에도 명확히 적었다.
기존 .m4a 녹음은 현재 구현으로 전사할 수 없고, 새 버전에서 다시 녹음해야 한다.
이건 사용자 경험상 아쉬운 부분이다. 하지만 초기 구현에서는 파일 변환기를 따로 넣기보다, 녹음 포맷을 모델 입력에 맞추는 것이 더 빠른 길이었다. 나중에는 기존 .m4a를 WAV로 변환하거나, sherpa-onnx가 처리 가능한 입력으로 변환하는 레이어를 둘 수 있다.
상세 화면에서 모델을 다루는 방식
상세 화면도 같이 바뀌었다.
현재 RecordingDetailScreen은 기본 전사 서비스로 SherpaOnnxTranscriptionService를 사용한다. 사용자가 음성을 글로 바꾸기를 누르면 다음 순서로 처리한다.
- 모델 디렉터리를 찾는다.
model.onnx와tokens.txt가 있는지 확인한다.- 파일 크기가 너무 작으면 불완전한 모델로 판단한다.
- 녹음 파일이 존재하는지 확인한다.
- 파일이 비어 있지 않은지 확인한다.
- WAV 파일인지 확인한다.
- sherpa-onnx binding을 초기화한다.
- SenseVoice offline recognizer를 만든다.
- WAV 샘플을 읽고 stream에 넣는다.
- decode 후 결과 텍스트를
Transcript로 저장한다.
모델 파일이 없으면 바로 실패로 끝내지 않고, 화면에 다운로드 액션을 보여준다. 다운로드는 GitHubSherpaOnnxModelDownloadService가 맡는다. 모델 파일은 전체 진행률의 95%, tokens.txt는 나머지 5% 정도로 나눠 진행률을 표시한다.
이 부분은 반복 수정이 꽤 있었을 것으로 보인다. 음성 인식 모델은 앱 코드만 있다고 되는 것이 아니라, 모델 파일의 배포 위치, 파일명, 저장 위치, 다운로드 진행률, 실패 메시지까지 모두 맞아야 한다. 하나라도 어긋나면 사용자에게는 그냥 “안 됨”으로 보인다.
그래서 상세 화면에는 이런 상태가 추가됐다.
- 모델 파일 없음
- 음성 인식 모델 다운로드
- 모델 다운로드 중
- 다운로드 진행률
- 다운로드 완료 후 다시 전사 요청 안내
- 모델 파일 불완전
- WAV가 아닌 기존 녹음 파일 안내
- 전사 실패 사유 표시
7월 7일에는 “녹음 중 받아쓴 텍스트를 저장한다”가 핵심이었다면, 7월 12일에는 “저장된 파일을 나중에 전사할 수 있게 준비한다”가 핵심이 됐다.
회의록 정리 모델을 바꾼 이유
음성 → 텍스트만으로는 말서랍의 목표가 끝나지 않는다.
말서랍이 만들고 싶은 것은 텍스트 덩어리가 아니라 읽을 수 있는 문서다. 회의록에는 제목, 요약, 핵심 포인트, 결정사항, 할 일, 질문/이슈가 있어야 한다. 그래서 전사 다음 단계인 회의록 생성도 같이 손보기 시작했다.
7월 7일 기준 회의록 생성은 규칙 기반이었다. 문장을 잘라서 앞부분을 핵심 포인트로 쓰고, 결정, 하기로, 확정, 해야, 담당, 검토 같은 키워드로 결정사항과 할 일을 뽑는 방식이었다.
이 방식은 빠르고 안정적이다. 모델 파일이 없어도 동작하고, 테스트하기 쉽고, 실패 가능성이 낮다. 하지만 회의록 품질에는 한계가 있다. 말에는 반복과 추임새가 많고, 회의에서 중요한 내용은 단어 하나로만 판단하기 어렵다. “다음 주까지 해보죠” 같은 문장은 할 일일 수 있지만, 키워드만으로는 맥락을 놓칠 수 있다.
그래서 7월 12일 기준으로는 llama_cpp_dart를 추가하고, Qwen3 0.6B GGUF 모델을 로컬 회의록 모델 후보로 붙였다.
현재 모델 파일명은 다음으로 고정되어 있다.
qwen3-0.6b-q4_k_m.gguf
다운로드 기본 URL은 Hugging Face의 Qwen3 0.6B GGUF 파일을 바라본다. 상세 화면에서 Qwen 모델이 없으면 회의록 AI 모델 다운로드 버튼을 보여준다.
왜 Qwen3 0.6B GGUF인가
Qwen3 0.6B GGUF를 선택한 이유는 균형 때문이다.
말서랍은 모바일 Android 앱이다. 큰 모델을 올리면 품질은 좋아질 수 있지만, 앱이 무거워지고 실행이 불안정해질 가능성이 크다. 특히 녹음 파일 전사 모델까지 이미 사용하는 상황에서는 회의록 모델까지 너무 커지면 실기기에서 감당하기 어렵다.
그래서 현재 선택은 작고 가벼운 로컬 모델을 붙여보고, 안 되면 규칙 기반으로 fallback하는 구조다.
코드에서도 이 방향이 보인다.
nGpuLayers = 0: GPU 의존 없이 CPU 중심 실행useMemorymap = true: 모델 로딩 부담을 줄이기 위한 설정nCtx = 2048: 모바일에서 감당 가능한 문맥 크기로 제한nThreads = 2: 과한 CPU 사용을 피함nPredict = 512: 회의록 출력 길이를 제한- timeout: 3분
- 실패하면 규칙 기반 회의록으로 fallback
그리고 전사 텍스트도 그대로 모두 모델에 넣지 않는다. 현재는 6000자까지 잘라서 프롬프트에 넣는다. 이 선택도 현실적이다. 긴 전사를 모두 넣으면 모바일 모델이 느려지거나 멈출 수 있고, 앱 사용자는 회의록 생성이 끝났는지 실패했는지 알기 어려워진다.
프롬프트는 JSON 출력을 강하게 요구한다.
- 제목
- 3문장 이내 요약
- 주요 논의
- 핵심 포인트
- 결정사항
- 할 일
- 질문/확인 필요
하지만 로컬 LLM은 항상 JSON만 깔끔하게 내보내지 않는다. 그래서 응답에서 <think>...</think> 블록을 제거하고, {부터 }까지를 찾아 JSON으로 파싱한다. 그래도 실패하면 plain text note로 저장하고, 모델 런타임 자체가 실패하면 규칙 기반 회의록으로 떨어진다.
이 구조는 “모델을 믿는다”보다 “모델이 실패할 것을 예상한다”에 가깝다. 모바일 로컬 AI에서는 이 태도가 더 맞다.
반복적으로 수정한 부분들
이번 단계에서 반복 수정이 많았던 부분은 크게 네 가지다.
첫 번째는 전사 방식의 중심 이동이다.
처음에는 녹음 중 Android SpeechRecognizer가 중심이었다. 이후 저장된 파일을 sherpa_onnx로 전사하는 방식이 추가되면서, 홈 화면의 문구와 상세 화면의 액션도 바뀌었다. “녹음 중 받아쓰기”에서 “녹음 후 상세 화면에서 음성을 글로 바꾸기”로 사용자 흐름이 이동했다.
두 번째는 파일 포맷 문제다.
기존 .m4a 녹음은 재생에는 좋지만 현재 sherpa-onnx 구현에는 맞지 않았다. 그래서 WAV, 16kHz, mono로 변경했다. 이 변경 때문에 기존 녹음 파일은 새 전사 기능으로 바로 처리할 수 없다. 사용자에게는 불편하지만, 모델 입력을 명확하게 맞추기 위한 선택이었다.
세 번째는 모델 배포와 다운로드 UX다.
모델 파일은 앱 코드와 다르게 관리되어야 한다. 앱에 무조건 포함하면 빌드와 설치가 무거워지고, 개인 프로젝트 단계에서는 모델 교체도 번거롭다. 그래서 GitHub Releases와 Hugging Face URL을 사용해 필요한 시점에 다운로드하는 구조를 만들었다.
하지만 다운로드 흐름에는 계속 챙길 것이 생긴다.
- URL이 설정되어 있는가
- HTTP 실패를 어떻게 보여줄 것인가
- 다운로드 중 진행률을 보여줄 것인가
- 다운로드 파일이 불완전하면 어떻게 판단할 것인가
.download임시 파일을 언제 실제 파일로 rename할 것인가- 사용자가 다운로드 후 다시 무엇을 눌러야 하는가
네 번째는 회의록 생성 실패 대응이다.
Qwen 모델은 JSON만 출력하라고 해도 꼭 JSON만 출력한다는 보장이 없다. 모델 파일이 없을 수도 있고, 파일이 덜 받아졌을 수도 있고, 런타임이 실패할 수도 있다. 그래서 Qwen 결과 파싱 실패 시 plain text note로 저장하고, 런타임 실패 시 규칙 기반 회의록으로 fallback하는 흐름을 넣었다.
이 반복 수정은 번거롭지만 중요한 과정이었다. AI 기능은 성공했을 때보다 실패했을 때 앱의 완성도가 드러난다.
현재 확인된 문제
아직 개발 완료 전이고, 가장 큰 문제는 긴 음성 파일 처리다.
특히 10분 이상 녹음 파일에서 크래시가 나는 문제가 남아 있다.
현재 sherpa-onnx 전사 구현은 WAV 파일을 이렇게 처리한다.
sherpa_onnx.readWave(request.audioPath)로 전체 WAV를 한 번에 읽는다.waveData.samples전체를 stream에 넣는다.recognizer.decode(stream)을 한 번 실행한다.- 하나의 결과 텍스트를 만든다.
짧은 파일에서는 이 방식이 단순하고 좋다. 하지만 10분 이상 녹음에서는 문제가 생길 수 있다.
WAV는 압축 포맷이 아니라 파일 크기가 빠르게 커진다. 16kHz mono라 해도 10분이면 샘플 수가 많고, 전체 샘플 배열을 한 번에 메모리에 올린 뒤 recognizer에 넘기면 Android 기기 메모리에 부담이 생긴다. 여기에 sherpa-onnx 모델 자체 메모리, Flutter 런타임, UI 상태, Qwen 모델까지 고려하면 크래시가 날 가능성이 높아진다.
즉, 지금 문제는 단순히 “예외 처리가 부족하다”가 아니라 처리 구조의 문제에 가깝다.
긴 파일은 한 번에 읽고 한 번에 decode하면 안 된다. 구간을 나누어 읽고, 구간별로 전사하고, 결과를 이어붙이는 구조가 필요하다.
앞으로 해야 할 것
가장 먼저 해야 할 일은 긴 음성 파일 전사 구조를 바꾸는 것이다.
현재 방식:
WAV 전체 읽기 -> 전체 샘플 accept -> 한 번 decode -> 하나의 transcript
바꿔야 할 방향:
WAV 메타데이터 읽기 -> 일정 길이 단위로 샘플 분할 -> 구간별 decode -> segment 저장 -> 전체 transcript 조립
예를 들어 30초 또는 60초 단위로 나누어 처리하고, 각 구간 결과를 TranscriptSegment로 저장하는 방향이 필요하다. 이미 Transcript 모델에는 segments가 있으므로, 데이터 구조는 준비되어 있다. 아직 실제 전사 서비스가 segment 단위 처리를 하지 않을 뿐이다.
두 번째는 전사를 UI thread에서 얼마나 분리할지 검토해야 한다.
긴 전사 작업은 시간이 오래 걸린다. 사용자가 상세 화면에서 버튼을 눌렀을 때 앱이 멈춘 것처럼 보이면 안 된다. isolate, background task, 작업 큐, 진행률 표시 중 어떤 방식으로 갈지 결정해야 한다.
세 번째는 파일 크기와 길이에 따른 방어 로직이다.
10분 이상에서 크래시가 난다면, 당장 완전한 chunking을 넣기 전에도 임시 방어가 필요하다.
- 일정 길이 이상이면 “긴 녹음은 아직 처리 준비 중” 안내
- 파일 크기가 너무 크면 전사 버튼 비활성화
- 전사 시작 전 예상 처리 시간 안내
- 실패 시 앱 전체 크래시가 아니라 실패 상태로 저장
- 로그에 파일 길이, 파일 크기, 모델 크기, 기기 정보를 남김
네 번째는 모델 저장과 검증을 더 안전하게 만드는 것이다.
현재는 모델 파일 크기가 너무 작으면 불완전하다고 판단한다. 하지만 실제 배포에서는 SHA256 검증까지 필요하다. 문서에는 SHA256SUMS를 올리도록 적어두었으니, 앱도 나중에는 manifest나 checksum을 읽어 파일 무결성을 확인하는 쪽으로 가야 한다.
다섯 번째는 Qwen 회의록 품질 검증이다.
Qwen3 0.6B는 모바일에서 시도하기 좋은 크기지만, 회의록 품질이 실제 사용 기대치에 맞는지는 별개의 문제다. 짧은 녹음, 중간 길이 녹음, 긴 회의 녹음으로 나누어 다음을 확인해야 한다.
- 한국어 추임새 제거가 잘 되는가
- 회의록 JSON을 안정적으로 출력하는가
- 결정사항과 할 일을 과하게 지어내지 않는가
- 긴 전사에서 앞부분만 보고 요약하는 문제가 없는가
- 실패 시 규칙 기반 fallback이 사용자에게 자연스러운가
여섯 번째는 기존 Android SpeechRecognizer 코드의 역할 정리다.
현재 코드에는 녹음 중 받아쓰기 흔적이 아직 남아 있다. 하지만 홈 화면은 파일 후처리 중심으로 움직였다. 이 기능을 완전히 제거할지, 실시간 미리보기용으로만 남길지, 아니면 sherpa-onnx 전사와 병행할지 결정해야 한다.
7월 12일 기준 상태
7월 12일 기준으로 말서랍은 아직 개발 완료 상태가 아니다.
하지만 중요한 전환점은 지났다.
이전에는 녹음 중 받아쓴 텍스트를 저장하는 앱에 가까웠다. 지금은 저장된 녹음 파일을 로컬 음성 인식 모델로 처리하고, 그 결과를 로컬 회의록 모델 또는 규칙 기반 fallback으로 정리하는 앱에 가까워졌다.
현재 들어간 주요 변경은 다음과 같다.
sherpa_onnx추가llama_cpp_dart추가- WAV 16kHz mono 녹음으로 변경
SherpaOnnxTranscriptionService추가- sherpa-onnx SenseVoice 모델 파일 검사
- GitHub Releases 기반 음성 인식 모델 다운로드 준비
docs/sherpa-onnx.md문서 추가LocalQwenNoteGenerationService추가- Qwen3 0.6B GGUF 모델 다운로드 준비
- Qwen JSON 출력 파싱과 fallback 처리
- 규칙 기반 회의록 fallback 유지
- 상세 화면에서 모델 다운로드/진행률/실패 사유 표시
- 기록 제목 변경과 상단 고정 기능 추가
- 전체 기록 목록 화면 추가
이제 남은 핵심은 명확하다.
긴 녹음 파일을 안정적으로 처리하는 것.
말서랍의 실제 사용 시나리오는 1분 테스트 녹음이 아니라 회의, 강의, 인터뷰다. 그러면 10분은 짧은 편일 수도 있다. 10분 이상에서 크래시가 난다면 MVP라고 말하기 어렵다.
그래서 다음 작업은 기능 추가보다 구조 안정화가 먼저다.
- 긴 WAV 파일 chunking 처리
- 전사 진행률 표시
- 전사 중 앱 크래시 방지
- 실패 상태 저장과 재시도
- segment 기반 Transcript 저장
- 긴 transcript를 Qwen에 넣기 전 요약/분할 전략
- 실제 Android 기기에서 1분, 5분, 10분, 20분 테스트
이번 단계에서 느낀 점
음성 → 텍스트 변환은 버튼 하나를 붙이는 일이 아니었다.
처음에는 “녹음 파일을 모델에 넣으면 텍스트가 나오겠지”라고 생각하기 쉽다. 하지만 실제 앱에서는 파일 포맷, 샘플레이트, 모델 파일 배포, 다운로드 실패, 모델 초기화, 긴 파일 메모리, UI 진행률, 실패 메시지, 개인정보 안내까지 모두 연결된다.
이번 단계에서 가장 크게 배운 것은, 로컬 AI 기능은 서버 API보다 더 많은 것을 앱이 직접 책임져야 한다는 점이다.
서버 API를 쓰면 모델 배포와 실행 환경은 서버가 가져간다. 대신 개인정보와 비용, 네트워크 의존성이 생긴다. 로컬 모델을 쓰면 사용자의 음성을 기기 밖으로 보내지 않아도 된다. 대신 모델 파일, 메모리, 속도, 크래시, 기기별 차이를 앱이 감당해야 한다.
말서랍은 개인정보와 신뢰를 중요하게 두는 앱이기 때문에 로컬 처리 방향이 맞다. 다만 그 방향을 선택한 순간, 긴 녹음 안정성은 피할 수 없는 숙제가 됐다.
“음성을 글로 바꾸는 기능은 완성된 기능이 아니라, 앱의 신뢰를 시험하는 가장 긴 통로였다.”
7월 12일 기준 말서랍은 그 통로의 입구를 지난 상태다. 이제는 짧은 데모가 아니라 긴 실제 녹음을 견디는 구조로 바꿔야 한다.
2026.07.12 정리
- 방향 변화: 녹음 중 받아쓰기 중심에서 저장된 WAV 파일 후처리 전사로 확장
- 전사 모델:
sherpa_onnx+ SenseVoice int8 모델 후보 - 모델 준비: GitHub Releases에서
model.onnx,tokens.txt다운로드 - 녹음 포맷:
.m4a에서 16kHz mono.wav로 변경 - 회의록 모델:
llama_cpp_dart+ Qwen3 0.6B GGUF 로컬 모델 시도 - fallback: Qwen 실패 시 규칙 기반 회의록 유지
- 반복 수정: 모델 누락, 다운로드 진행률, WAV 호환성, JSON 파싱, 실패 메시지 정리
- 남은 문제: 10분 이상 음성 파일 전사 크래시, chunking 미구현, 긴 녹음 QA 미완료