diff --git a/sundol-backend/src/main/java/com/sundol/controller/NoteController.java b/sundol-backend/src/main/java/com/sundol/controller/NoteController.java index 6aa2135..28baacc 100644 --- a/sundol-backend/src/main/java/com/sundol/controller/NoteController.java +++ b/sundol-backend/src/main/java/com/sundol/controller/NoteController.java @@ -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 chunkTexts = new ArrayList<>(); + List 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 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 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 {