Repository navigation
Fix/#47 tts error #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2797108
d1d4d44
0b5bddf
450646c
329bbd8
3cc9cea
cab82cf
0873c56
589fc8c
1bbfd23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # 녹음 저장 후 다음 질문으로 넘어가지 않을 때 | ||
|
|
||
| ## 저장 응답과 면접 진행 | ||
|
|
||
| `POST /api/interviews/{id}/recordings`의 201은 녹음 파일 저장 결과다. | ||
| Chat의 답변 제출과는 별도이므로 이 응답만으로 다음 질문이 반환되었다고 판단하지 않는다. | ||
| Chat은 답변 제출이 접수되어야 세션에 텍스트를 저장하고 다음 질문을 반환한다. | ||
| 빈 답변을 성공 처리하거나 질문을 자동으로 건너뛰지는 않는다. | ||
|
|
||
| ## 확인 순서 | ||
|
|
||
| 1. 브라우저 Network에서 `POST /chat/interviews/{sessionId}/answers`의 요청 유무를 확인한다. | ||
| 2. 요청이 있다면 상태 코드와 응답 본문의 다음 질문 또는 오류를 확인한다. | ||
| 3. Chat의 `[ANSWER ACCEPTED]` 로그에서 세션·면접·질문 식별자를 확인한다. | ||
| 4. 요청 자체가 없다면 녹음 종료 후 음성 인식 결과와 화면 오류를 확인한다. | ||
| 5. 동일 질문에 녹음만 반복 저장되었다면 각 시도의 답변 제출 여부를 따로 확인한다. | ||
|
|
||
| `answer: null`인 최종 결과만으로 네트워크·브라우저·음성 인식 중 어느 원인인지 확정하지 않는다. | ||
| 답변 내용이나 인증 토큰을 진단 로그에 추가하지 않는다. | ||
|
|
||
| ## Web 동작을 확인할 때 | ||
|
|
||
| 2026-10-19 조사 시 Web `dev`의 `useInterviewSession.ts`는 음성 인식 텍스트가 | ||
| 빈 문자열이면 인식 실패를 표시하고 Chat 제출을 중단했다. | ||
| 이는 Chat 저장소가 보장하는 계약이 아니라 다른 저장소의 특정 시점 관찰이다. | ||
| Web 변경 또는 배포 버전 차이가 있으면 아래 코드와 실제 브라우저 동작을 다시 대조한다. | ||
|
|
||
| - [Web 답변 제출 흐름](https://github.com/Alpha-origin/Repit-web/blob/dev/src/features/interview-page/interview/model/useInterviewSession.ts) | ||
| - [Web 음성 인식 처리](https://github.com/Alpha-origin/Repit-web/blob/dev/src/features/interview-page/interview/model/useVoiceAnswer.ts) | ||
|
|
||
| 질문 음성의 수신 오류는 [TTS 수신 한도](tts-audio-buffer.md)를 참고한다. | ||
| TTS 오류 수정이 브라우저 음성 인식 실패를 해결하는 것은 아니다. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # ElevenLabs 질문 음성 수신 한도 | ||
|
|
||
| ## 문제와 변경 범위 | ||
|
|
||
| ElevenLabs 음성 응답은 `ElevenLabsTtsClient`에서 `byte[]`로 모은 뒤 기존 저장 경로로 넘긴다. | ||
| 전용 WebClient에 별도 수신 한도가 없어 256KiB를 넘는 응답은 | ||
| `DataBufferLimitException`으로 실패했다. 로컬 HTTP 서버의 320KiB 응답으로 재현한다. | ||
|
|
||
| 전용 WebClient의 수신 한도를 기본 8MiB로 설정한다. 다른 WebClient에는 적용하지 않는다. | ||
| 기존 `byte[]` 합성·저장 인터페이스, 타임아웃, 큐 동시성, 오디오 응답 계약은 유지한다. | ||
| TTS가 켜져 있으면 0 이하 또는 2147483647바이트를 넘는 설정은 기동 시 거부한다. | ||
| TTS가 꺼져 있으면 버퍼 설정 대신 안전한 8MiB 기본값을 사용해 전체 서버의 기동을 막지 않는다. | ||
| 형식이 잘못된 값(예: `invalid`)은 설정 바인딩 단계에서 거부한다. | ||
| 한도를 초과하면 재시도 불가능한 `TtsClientException`으로 변환하여 | ||
| 동일한 음성 합성을 반복 요청하지 않는다. 오디오 상태는 FAILED이고, | ||
| 저장·조회 응답의 `errorCode`는 `TTS_AUDIO_TOO_LARGE`다. 다른 합성 실패는 기존 | ||
| `TTS_GENERATION_FAILED`, 업로드 실패는 `AUDIO_STORAGE_FAILED`를 유지한다. | ||
|
|
||
| ## 설정과 배포 | ||
|
|
||
| | 항목 | 값 | | ||
| | --- | --- | | ||
| | 설정 키 | `tts.elevenlabs.max-in-memory-size` | | ||
| | 환경변수 | `ELEVENLABS_MAX_IN_MEMORY_SIZE` | | ||
| | 기본값 | `8388608` 바이트 (8MiB) | | ||
| | 허용값 | `8388608`, `8MB`, `512KB` 등 DataSize 형식 | | ||
| | 활성화 시 범위 | 1~2147483647바이트 | | ||
|
|
||
| Docker Compose도 이 환경변수를 Chat 컨테이너에 전달한다. | ||
| 설정을 바꾼 뒤 컨테이너를 재생성해야 적용된다. | ||
| 기본값은 `TtsProperties.ElevenLabs.DEFAULT_MAX_IN_MEMORY_SIZE`, `application.yml`, | ||
| `docker-compose.yml` 세 곳에 있으므로 기본값 변경 시 함께 수정한다. | ||
| 합성 전역 동시성 기본값 4에서 오디오 바이트 배열만 최대 약 32MiB를 사용하며, | ||
| 수신 버퍼·업로드·런타임 메모리는 별도로 필요하다. 동시성과 한도를 함께 고려한다. | ||
| 배포 뒤 새로 준비한 면접의 긴 질문에서 오디오 READY와 재생을 확인한다. | ||
| 이미 FAILED로 저장된 오디오는 이 변경만으로 자동 재생성되지 않는다. | ||
|
|
||
| ## 클라이언트 수명과 공통 설정 | ||
|
|
||
| Boot가 제공한 `WebClient.Builder`를 복제하므로 공통 커스터마이저를 유지하면서 | ||
| ElevenLabs 전용 URL·코덱·커넥터를 설정한다. 원본 빌더와 다른 클라이언트는 바꾸지 않는다. | ||
| `elevenLabsConnectionProvider`는 Spring 빈으로 관리하고 컨텍스트 종료 시 dispose한다. | ||
| HTTP 테스트도 사용한 풀을 명시적으로 정리한다. | ||
|
|
||
| 녹음 201 이후 면접 진행 문제는 [답변 제출 진단](interview-answer-troubleshooting.md)을 참고한다. | ||
|
|
||
| ## 검증 | ||
|
|
||
| `./gradlew test`로 기본 한도 초과였던 오디오의 원본 바이트 수신, | ||
| 설정 한도 경계·초과, 단위 바인딩, TTS 활성화별 설정 검증, 풀 종료, | ||
| 원본 빌더 격리, 오류 코드 전달, 기존 HTTP·빈 오디오 실패 처리를 검증한다. | ||
| 실제 ElevenLabs 호출·유료 사용·운영 배포 없이 로컬 HTTP 서버를 사용한다. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import com.person.repit.tts.config.TtsProperties; | ||
| import com.person.repit.tts.domain.TtsInput; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.core.io.buffer.DataBufferLimitException; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.reactive.function.client.ClientResponse; | ||
|
|
@@ -59,6 +60,10 @@ private Mono<byte[]> readAudio(ClientResponse response) { | |
| )); | ||
| } | ||
| return response.bodyToMono(byte[].class) | ||
| .onErrorMap(DataBufferLimitException.class, error -> new TtsClientException( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안] 한도 초과를 다른 실패와 구분할 수 있으면 좋겠어요 재시도하지 않게 바꾼 건 좋습니다. 이전에는 다만 |
||
| "ElevenLabs 오디오가 수신 한도를 초과했습니다: " | ||
| + properties.getElevenlabs().getMaxInMemorySize().toBytes() + "바이트", | ||
| false, "TTS_AUDIO_TOO_LARGE", error)) | ||
| .filter(bytes -> bytes.length > 0) | ||
| .switchIfEmpty(Mono.error( | ||
| new TtsClientException("ElevenLabs 오디오가 비어 있습니다", false) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| package com.person.repit.tts.config; | ||
|
|
||
| import com.sun.net.httpserver.HttpServer; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.ValueSource; | ||
| import org.springframework.context.annotation.AnnotationConfigApplicationContext; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.util.unit.DataSize; | ||
| import org.springframework.web.reactive.function.client.ClientResponse; | ||
| import org.springframework.web.reactive.function.client.ClientRequest; | ||
| import org.springframework.web.reactive.function.client.WebClient; | ||
| import reactor.core.publisher.Mono; | ||
| import reactor.netty.resources.ConnectionProvider; | ||
|
|
||
| import java.net.InetSocketAddress; | ||
| import java.time.Duration; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| class ElevenLabsWebClientConfigTest { | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(longs = {0, -1, 2147483648L}) | ||
| void TTS가_켜져_있으면_잘못된_수신_한도로_기동하지_않는다(long bytes) { | ||
| var properties = properties(true, bytes); | ||
| try (var context = context(properties, WebClient.builder())) { | ||
| assertThatThrownBy(context::refresh).hasRootCauseInstanceOf(IllegalArgumentException.class); | ||
| } | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(longs = {0, -1, 2147483648L}) | ||
| void TTS가_꺼져_있으면_잘못된_수신_한도로_서버_기동을_막지_않는다(long bytes) { | ||
| var properties = properties(false, bytes); | ||
| try (var context = context(properties, WebClient.builder())) { | ||
| context.refresh(); | ||
| assertThat(context.getBean("elevenLabsWebClient")).isInstanceOf(WebClient.class); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void TTS_클라이언트는_주입된_빌더의_설정을_유지하고_원본을_바꾸지_않는다() { | ||
| List<ClientRequest> requests = new ArrayList<>(); | ||
| var builder = WebClient.builder().baseUrl("http://original.test") | ||
| .defaultHeader("X-Customizer", "retained") | ||
| .exchangeFunction(request -> { | ||
| requests.add(request); | ||
| return Mono.just(ClientResponse.create(HttpStatus.OK).body("ok").build()); | ||
| }); | ||
| var properties = properties(true, 1024); | ||
| properties.getElevenlabs().setBaseUrl("http://tts.test"); | ||
|
|
||
| try (var context = context(properties, builder)) { | ||
| context.refresh(); | ||
| var client = context.getBean("elevenLabsWebClient", WebClient.class); | ||
| client.get().uri("/probe").retrieve().bodyToMono(String.class).block(Duration.ofSeconds(5)); | ||
| builder.build().get().uri("/probe").retrieve().bodyToMono(String.class).block(Duration.ofSeconds(5)); | ||
| } | ||
|
|
||
| assertThat(requests).hasSize(2); | ||
| assertThat(requests.getFirst().headers().getFirst("X-Customizer")).isEqualTo("retained"); | ||
| assertThat(requests.getFirst().url().getHost()).isEqualTo("tts.test"); | ||
| assertThat(requests.getLast().url().getHost()).isEqualTo("original.test"); | ||
| assertThat(requests.getLast().headers().getAccept()).isEmpty(); | ||
| } | ||
|
|
||
| @Test | ||
| void 컨텍스트가_닫히면_실제_HTTP_연결을_사용한_풀을_정리한다() throws Exception { | ||
| var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); | ||
| server.createContext("/probe", exchange -> { | ||
| try (exchange) { | ||
| exchange.sendResponseHeaders(204, -1); | ||
| } | ||
| }); | ||
| server.start(); | ||
| try { | ||
| var properties = properties(true, 1024); | ||
| properties.getElevenlabs().setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); | ||
| ConnectionProvider provider; | ||
| try (var context = context(properties, WebClient.builder())) { | ||
| context.refresh(); | ||
| provider = context.getBean("elevenLabsConnectionProvider", ConnectionProvider.class); | ||
| context.getBean("elevenLabsWebClient", WebClient.class).get().uri("/probe") | ||
| .retrieve().toBodilessEntity().block(Duration.ofSeconds(5)); | ||
| } | ||
| assertThat(provider.isDisposed()).isTrue(); | ||
| } finally { | ||
| server.stop(0); | ||
| } | ||
| } | ||
|
|
||
| private TtsProperties properties(boolean enabled, long bytes) { | ||
| var properties = new TtsProperties(); | ||
| properties.setEnabled(enabled); | ||
| properties.getElevenlabs().setMaxInMemorySize(DataSize.ofBytes(bytes)); | ||
| return properties; | ||
| } | ||
|
|
||
| private AnnotationConfigApplicationContext context(TtsProperties properties, WebClient.Builder builder) { | ||
| var context = new AnnotationConfigApplicationContext(); | ||
| context.registerBean(TtsProperties.class, () -> properties); | ||
| context.registerBean(WebClient.Builder.class, () -> builder); | ||
| context.register(ElevenLabsWebClientConfig.class); | ||
| return context; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] 기본값 8388608이 세 군데에 있어요
TtsProperties(8 * 1024 * 1024),application.yml(${ELEVENLABS_MAX_IN_MEMORY_SIZE:8388608}), 여기 compose 기본값에 같은 값이 각각 하드코딩돼 있습니다. compose는 빈 문자열이 넘어가지 않도록 기본값이 필요하니 의도된 거라면 그대로 두셔도 됩니다. 기본값을 바꿀 때 세 곳을 함께 바꿔야 한다는 점만 문서에 적어 두면 좋겠어요.