note STT: OpenRouter 경로 오버랩 청크 분할 + 퍼지 디덥 병합

대용량 오디오가 OpenRouter(Gemini) 단일 요청 한도(~20MB)를 초과해 502로
실패하고, 깨진 Gemma fallback(ollama runner crash)으로 넘어가 전체 실패하던
문제 수정.

- 공통 청크 분할기 transcribeChunked(전략 패턴)로 OpenRouter/Gemma 통일
- CHUNK_SECONDS=180s, OVERLAP_SECONDS=10s 오버랩 분할(경계 단어 손실 방지)
- mergeWithOverlapDedup: 인접 청크 토큰 위치정렬 80% 일치로 중복 제거,
  미검출 시 줄바꿈 안전 연결(누락 방지)
- 청크 실패는 기록·로그 후 [변환 실패한 구간 번호] 명시, 전부 실패 시 예외
- transcribeAsync의 Gemma fallback도 단발→transcribeWithGemma(청크) 사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 05:36:40 +00:00
parent 7bc0464afc
commit 446969b862

View File

@@ -19,6 +19,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
@@ -217,9 +218,7 @@ public class NoteController {
if (rawResult == null || rawResult.isBlank()) {
try {
noteRepository.updateContent(noteId, "Gemma로 음성 변환 중...");
Path wavFile = convertToWav(audioFile);
rawResult = transcribeChunk(wavFile);
cleanup(wavFile, audioFile);
rawResult = transcribeWithGemma(audioFile);
} catch (Exception e) {
log.error("All STT failed for note {}", noteId, e);
noteRepository.updateContent(noteId, "모든 음성 변환 실패: " + e.getMessage());
@@ -300,27 +299,53 @@ public class NoteController {
}
private static final int CHUNK_SECONDS = 180; // 3분 단위 분할
private static final int OVERLAP_SECONDS = 10; // 청크 간 오버랩 (경계 단어 손실 방지)
private static final int MAX_OVERLAP_TOKENS = 60; // 병합 시 오버랩 탐색 최대 토큰 수
private String transcribeWithGemma(Path audioFile) throws IOException, InterruptedException {
Path wavFile = convertToWav(audioFile);
try {
String result = transcribeChunked(wavFile, this::transcribeChunk);
if (result.isBlank()) throw new IOException("Gemma STT returned empty for all chunks");
return result;
} finally {
cleanup(wavFile, audioFile);
}
}
/**
* 단일 오디오 청크(wav)를 텍스트로 변환하는 전략. STT 백엔드(Gemma/OpenRouter)별로 주입한다.
*/
@FunctionalInterface
private interface ChunkTranscriber {
String transcribe(Path wavChunk) throws IOException, InterruptedException;
}
/**
* convertToWav 된 wav 파일을 오버랩 청크로 분할하여 각 청크를 transcriber로 전사한 뒤
* 퍼지 디덥으로 병합한다. duration ≤ CHUNK_SECONDS면 분할 없이 한 번에 처리한다.
* 각 청크는 [start, start+CHUNK_SECONDS] 구간이며 step(= CHUNK_SECONDS - OVERLAP_SECONDS)만큼 전진한다.
*/
private String transcribeChunked(Path wavFile, ChunkTranscriber transcriber)
throws IOException, InterruptedException {
double duration = getAudioDuration(wavFile);
log.info("Audio duration: {}s", duration);
if (duration <= CHUNK_SECONDS) {
String result = transcribeChunk(wavFile);
cleanup(wavFile, audioFile);
return result;
return transcriber.transcribe(wavFile);
}
// 긴 오디오: 3분 단위로 분할
int chunks = (int) Math.ceil(duration / CHUNK_SECONDS);
log.info("Splitting audio into {} chunks of {}s", chunks, CHUNK_SECONDS);
StringBuilder fullText = new StringBuilder();
for (int i = 0; i < chunks; i++) {
int start = i * CHUNK_SECONDS;
Path chunkFile = wavFile.getParent().resolve("chunk_" + i + "_" + System.currentTimeMillis() + ".wav");
int step = CHUNK_SECONDS - OVERLAP_SECONDS;
log.info("Splitting audio (duration {}s) into chunks of {}s with {}s overlap",
(int) Math.ceil(duration), CHUNK_SECONDS, OVERLAP_SECONDS);
List<String> chunkTexts = new ArrayList<>();
List<Integer> failedChunks = new ArrayList<>();
int index = 0;
for (int start = 0; start < duration; start += step) {
index++;
Path chunkFile = wavFile.getParent().resolve(
"chunk_" + index + "_" + System.currentTimeMillis() + ".wav");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-i", wavFile.toString(),
"-ss", String.valueOf(start), "-t", String.valueOf(CHUNK_SECONDS),
@@ -329,28 +354,108 @@ public class NoteController {
pb.redirectErrorStream(true);
Process proc = pb.start();
proc.getInputStream().readAllBytes();
proc.waitFor();
log.info("Transcribing chunk {}/{} ({}s-{}s)", i + 1, chunks, start, Math.min(start + CHUNK_SECONDS, (int) duration));
try {
String chunkText = transcribeChunk(chunkFile);
if (!chunkText.isBlank()) {
if (fullText.length() > 0) fullText.append("\n\n");
fullText.append(chunkText);
}
} catch (Exception e) {
log.warn("Chunk {} failed: {}", i + 1, e.getMessage());
fullText.append("\n\n[chunk ").append(i + 1).append(" 변환 실패]");
} finally {
try { Files.deleteIfExists(chunkFile); } catch (Exception ignored) {}
int exitCode = proc.waitFor();
if (exitCode != 0) {
deleteQuietly(chunkFile);
throw new IOException("청크 분할 실패 (ffmpeg exit " + exitCode + ", chunk " + index + ")");
}
log.info("Transcribing chunk {} ({}s-{}s)", index, start,
Math.min(start + CHUNK_SECONDS, (int) Math.ceil(duration)));
try {
String chunkText = transcriber.transcribe(chunkFile);
if (!chunkText.isBlank()) chunkTexts.add(chunkText.strip());
} catch (IOException e) {
log.warn("Chunk {} transcription failed: {}", index, e.getMessage());
failedChunks.add(index);
} finally {
deleteQuietly(chunkFile);
}
if (start + CHUNK_SECONDS >= duration) break;
}
cleanup(wavFile, audioFile);
String result = fullText.toString().strip();
if (result.isBlank()) throw new IOException("Gemma STT returned empty for all chunks");
return result;
if (chunkTexts.isEmpty() && !failedChunks.isEmpty()) {
throw new IOException("모든 청크 변환 실패 (" + failedChunks.size() + "개)");
}
String merged = mergeWithOverlapDedup(chunkTexts);
if (!failedChunks.isEmpty()) {
log.warn("Transcription incomplete: {} chunk(s) failed: {}", failedChunks.size(), failedChunks);
merged = merged + "\n\n[변환 실패한 구간 번호: " + failedChunks + "]";
}
return merged;
}
/**
* 오버랩 분할로 생성된 인접 청크 텍스트들을 병합한다.
* 앞 청크 꼬리 토큰과 뒤 청크 머리 토큰의 최장 일치 구간(정규화·80% 허용)을 찾아 중복을 제거한다.
* 일치 구간을 못 찾으면 누락 방지를 위해 줄바꿈으로 안전하게 이어붙인다.
*/
private String mergeWithOverlapDedup(List<String> chunkTexts) {
if (chunkTexts.isEmpty()) return "";
StringBuilder merged = new StringBuilder(chunkTexts.get(0));
for (int i = 1; i < chunkTexts.size(); i++) {
String next = chunkTexts.get(i);
String[] nextTokens = next.trim().split("\\s+");
int dropTokens = overlapTokenCount(merged.toString(), next);
if (dropTokens >= nextTokens.length) {
continue; // 다음 청크 전체가 직전 내용과 중복
}
StringBuilder remainder = new StringBuilder();
for (int t = dropTokens; t < nextTokens.length; t++) {
if (remainder.length() > 0) remainder.append(' ');
remainder.append(nextTokens[t]);
}
merged.append(dropTokens > 0 ? " " : "\n\n").append(remainder);
}
return merged.toString().strip();
}
/**
* prev의 꼬리 토큰들과 next의 머리 토큰들이 겹치는 최장 길이(토큰 수)를 반환한다.
* 정규화(소문자·구두점 제거) 후 토큰 단위로 비교하며, 비어있지 않은 토큰이 80% 이상 일치하면
* 같은 구간으로 본다. 겹침이 없으면 0을 반환한다.
*/
private int overlapTokenCount(String prev, String next) {
String[] prevTokens = normalizeTokens(prev);
String[] nextTokens = normalizeTokens(next);
int maxL = Math.min(Math.min(prevTokens.length, nextTokens.length), MAX_OVERLAP_TOKENS);
for (int len = maxL; len >= 1; len--) {
int matches = 0;
for (int k = 0; k < len; k++) {
String a = prevTokens[prevTokens.length - len + k];
String b = nextTokens[k];
if (!a.isEmpty() && a.equals(b)) matches++;
}
if (matches >= (int) Math.ceil(len * 0.8)) {
return len;
}
}
return 0;
}
/**
* 공백 단위로 토큰화하고 각 토큰을 소문자화·구두점 제거로 정규화한다.
* 원본 토큰과 1:1 대응(같은 개수)을 유지하여 인덱스가 어긋나지 않도록 한다.
*/
private String[] normalizeTokens(String s) {
String trimmed = s.trim();
if (trimmed.isEmpty()) return new String[0];
String[] raw = trimmed.split("\\s+");
String[] out = new String[raw.length];
for (int i = 0; i < raw.length; i++) {
out[i] = raw[i].toLowerCase().replaceAll("\\p{Punct}", "");
}
return out;
}
private void deleteQuietly(Path file) {
try {
Files.deleteIfExists(file);
} catch (IOException e) {
log.warn("임시 파일 삭제 실패 {}: {}", file, e.getMessage());
}
}
private String transcribeChunk(Path wavFile) throws IOException, InterruptedException {
@@ -483,21 +588,27 @@ public class NoteController {
}
/**
* OpenRouter API (Gemini 2.5 Flash)를 사용하여 오디오 STT. 한 번에 전체 파일 처리 가능.
* OpenRouter API (Gemini 2.5 Flash)를 사용하여 오디오 STT.
* 대용량 파일은 OpenRouter 단일 요청 한도(약 20MB)를 넘기므로 wav로 변환 후 오버랩 청크로 분할하여 전사한다.
*/
private String transcribeWithOpenRouter(Path audioFile) throws IOException, InterruptedException {
byte[] audioBytes = Files.readAllBytes(audioFile);
Path wavFile = convertToWav(audioFile);
try {
String result = transcribeChunked(wavFile, this::transcribeChunkOpenRouter);
if (result.isBlank()) throw new IOException("OpenRouter STT returned empty result");
return result;
} finally {
cleanup(wavFile, audioFile);
}
}
/**
* 단일 wav 청크를 OpenRouter(Gemini)로 전사한다. 16kHz mono wav 청크를 audio/wav 형식으로 전송한다.
*/
private String transcribeChunkOpenRouter(Path wavFile) throws IOException, InterruptedException {
byte[] audioBytes = Files.readAllBytes(wavFile);
String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
String mimeType = "audio/wav";
String name = audioFile.getFileName().toString().toLowerCase();
if (name.endsWith(".mp3")) mimeType = "audio/mpeg";
else if (name.endsWith(".m4a")) mimeType = "audio/mp4";
else if (name.endsWith(".ogg")) mimeType = "audio/ogg";
else if (name.endsWith(".webm")) mimeType = "audio/webm";
else if (name.endsWith(".flac")) mimeType = "audio/flac";
log.info("OpenRouter STT: {} ({} MB, {})", name, audioBytes.length / 1024 / 1024, mimeType);
log.info("OpenRouter chunk: {} ({} MB)", wavFile.getFileName(), audioBytes.length / 1024 / 1024);
// OpenRouter chat/completions API with audio input
String payload = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
@@ -507,7 +618,7 @@ public class NoteController {
"content", List.of(
Map.of("type", "input_audio", "input_audio", Map.of(
"data", base64Audio,
"format", mimeType.substring(mimeType.indexOf('/') + 1)
"format", "wav"
)),
Map.of("type", "text", "text",
"Transcribe the audio accurately. Output only the spoken content in its original language. " +
@@ -526,7 +637,6 @@ public class NoteController {
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("OpenRouter STT error {}: {}", response.statusCode(),
response.body().substring(0, Math.min(500, response.body().length())));
@@ -534,13 +644,7 @@ public class NoteController {
}
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body());
String text = root.path("choices").path(0).path("message").path("content").asText("").strip();
if (text.isBlank()) {
throw new IOException("OpenRouter STT returned empty result");
}
return text;
return root.path("choices").path(0).path("message").path("content").asText("").strip();
}
private double getAudioDuration(Path audioFile) throws IOException, InterruptedException {