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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user