텍스트-음성
텍스트-음성 (TTS) API
Spring AI는 TextToSpeechModel과 StreamingTextToSpeechModel 인터페이스를 통해 Text-To-Speech(TTS)를 위한 통합 API를 제공해요. 덕분에 서로 다른 TTS 제공자에서도 동작하는 이식성 높은 코드를 작성할 수 있죠. 이 글에서는 공통 인터페이스와 함께, 제공자에 구애받지 않는(provider-agnostic) 코드를 작성하는 방법과 스트리밍, REST 컨트롤러 예제까지 살펴볼게요.
출처: 문서
본문
Spring AI는 TextToSpeechModel과 StreamingTextToSpeechModel 인터페이스를 통해 Text-To-Speech(TTS)를 위한 통합 API를 제공해요. 이를 통해 서로 다른 TTS 제공자에서도 동작하는 이식성 높은 코드를 작성할 수 있어요.
Supported Providers
Common Interface
모든 TTS 제공자는 다음과 같은 공유 인터페이스를 구현해요:
TextToSpeechModel
TextToSpeechModel 인터페이스는 텍스트를 음성으로 변환하는 메서드를 제공해요:
public interface TextToSpeechModel extends Model<TextToSpeechPrompt, TextToSpeechResponse>, StreamingTextToSpeechModel {
/**
* Converts text to speech with default options.
*/
default byte[] call(String text) {
// Default implementation
}
/**
* Converts text to speech with custom options.
*/
TextToSpeechResponse call(TextToSpeechPrompt prompt);
/**
* Returns the default options for this model.
*/
default TextToSpeechOptions getOptions() {
// Default implementation
}
}
StreamingTextToSpeechModel
StreamingTextToSpeechModel 인터페이스는 실시간 오디오 스트리밍을 위한 메서드를 제공해요:
@FunctionalInterface
public interface StreamingTextToSpeechModel extends StreamingModel<TextToSpeechPrompt, TextToSpeechResponse> {
/**
* Streams text-to-speech responses with metadata.
*/
Flux<TextToSpeechResponse> stream(TextToSpeechPrompt prompt);
/**
* Streams audio bytes for the given text.
*/
default Flux<byte[]> stream(String text) {
// Default implementation
}
}
TextToSpeechPrompt
TextToSpeechPrompt 클래스는 입력 텍스트와 옵션을 캡슐화해요:
TextToSpeechPrompt prompt = new TextToSpeechPrompt(
"Hello, this is a text-to-speech example.",
options
);
TextToSpeechResponse
TextToSpeechResponse 클래스는 생성된 오디오와 메타데이터를 담아요:
TextToSpeechResponse response = model.call(prompt);
byte[] audioBytes = response.getResult().getOutput();
TextToSpeechResponseMetadata metadata = response.getMetadata();
Writing Provider-Agnostic Code
공유 TTS 인터페이스의 주요 이점 중 하나는, 수정 없이 어떤 TTS 제공자에서도 동작하는 코드를 작성할 수 있다는 점이에요. 실제 제공자(OpenAI, ElevenLabs 등)는 Spring Boot 설정에 의해 결정되므로, 애플리케이션 코드를 바꾸지 않고도 제공자를 전환할 수 있어요.
Basic Service Example
공유 인터페이스를 사용하면 어떤 TTS 제공자에서도 동작하는 코드를 작성할 수 있어요:
@Service
public class NarrationService {
private final TextToSpeechModel textToSpeechModel;
public NarrationService(TextToSpeechModel textToSpeechModel) {
this.textToSpeechModel = textToSpeechModel;
}
public byte[] narrate(String text) {
// Works with any TTS provider
return textToSpeechModel.call(text);
}
public byte[] narrateWithOptions(String text, TextToSpeechOptions options) {
TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, options);
TextToSpeechResponse response = textToSpeechModel.call(prompt);
return response.getResult().getOutput();
}
}
이 서비스는 OpenAI, ElevenLabs 또는 다른 어떤 TTS 제공자와도 매끄럽게 동작하며, 실제 구현은 Spring Boot 설정에 의해 결정돼요.
Advanced Example: Multi-Provider Support
여러 TTS 제공자를 동시에 지원하는 애플리케이션도 만들 수 있어요:
@Service
public class MultiProviderNarrationService {
private final Map<String, TextToSpeechModel> providers;
public MultiProviderNarrationService(List<TextToSpeechModel> models) {
// Spring will inject all available TextToSpeechModel beans
this.providers = models.stream()
.collect(Collectors.toMap(
model -> model.getClass().getSimpleName(),
model -> model
));
}
public byte[] narrateWithProvider(String text, String providerName) {
TextToSpeechModel model = providers.get(providerName);
if (model == null) {
throw new IllegalArgumentException("Unknown provider: " + providerName);
}
return model.call(text);
}
public Set<String> getAvailableProviders() {
return providers.keySet();
}
}
Streaming Audio Example
공유 인터페이스는 실시간 오디오 생성을 위한 스트리밍도 지원해요:
@Service
public class StreamingNarrationService {
private final TextToSpeechModel textToSpeechModel;
public StreamingNarrationService(TextToSpeechModel textToSpeechModel) {
this.textToSpeechModel = textToSpeechModel;
}
public Flux<byte[]> streamNarration(String text) {
// TextToSpeechModel extends StreamingTextToSpeechModel
return textToSpeechModel.stream(text);
}
public Flux<TextToSpeechResponse> streamWithMetadata(String text, TextToSpeechOptions options) {
TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, options);
return textToSpeechModel.stream(prompt);
}
}
REST Controller Example
제공자에 구애받지 않는 TTS로 REST API를 만들어볼게요:
@RestController
@RequestMapping("/api/tts")
public class TextToSpeechController {
private final TextToSpeechModel textToSpeechModel;
public TextToSpeechController(TextToSpeechModel textToSpeechModel) {
this.textToSpeechModel = textToSpeechModel;
}
@PostMapping(value = "/synthesize", produces = "audio/mpeg")
public ResponseEntity<byte[]> synthesize(@RequestBody SynthesisRequest request) {
byte[] audio = textToSpeechModel.call(request.text());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("audio/mpeg"))
.header("Content-Disposition", "attachment; filename=\"speech.mp3\"")
.body(audio);
}
@GetMapping(value = "/stream", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public Flux<byte[]> streamSynthesis(@RequestParam String text) {
return textToSpeechModel.stream(text);
}
record SynthesisRequest(String text) {}
}
Consuming the Stream as a Blocking InputStream
일부 API는 리액티브 Flux가 아니라 java.io.InputStream만 받아요. 오디오 재생 라이브러리나 블로킹 I/O로 디스크에 점진적으로 쓰는 코드가 전형적인 예시예요. Flux<byte[]>를 InputStream으로 연결(bridge)하는 것은 간단하지만, 그러한 브리지가 실제로 무엇을 보장하는지 정확히 이해하는 게 좋아요.
잘 동작하는 브리지는 Flux에서 한 번에 하나의 청크를 요청해서, InputStream 소비자가 읽은 것보다 이미 수신된 청크 하나 이상을 버퍼링하지 않아야 해요. 또한 end-of-stream에 도달하기 전에 InputStream이 닫히면 기저의 구독(subscription)을 취소해서, 예를 들어 재생을 중단하는 소비자가 제공자 연결을 즉시 해제하고 응답이 완료될 때까지 계속 켜두지 않도록 해야 해요. Flux#toIterable()에 기반한 브리지는 이 두 번째 속성을 제공할 수 없는데, 그 반복자(iterator)는 구독을 취소할 방법을 노출하지 않기 때문이에요. reactor.core.publisher.BaseSubscriber를 통한 브리지는 두 속성을 모두 제공해요: hookOnNext에서 한 번에 한 요소씩 요청하고, close()에서 Subscriber.dispose()로 취소하면 돼요.
InputStream 소비자를 일시 중지하면 HTTP/2를 사용하는 제공자(예: OpenAI)의 클라이언트에 실제 backpressure가 전파돼요. 기저 전송 계층인 OkHttp는 애플리케이션 코드가 실제로 버퍼링된 바이트를 읽을 때만 서버에 더 보내라고 알리는 WINDOW_UPDATE 프레임을 보내기 때문에, 멈춘 소비자는 결국 서버가 그 연결에서 더 이상 데이터를 보내지 못하게 해요. 이 효과는 실재하지만 세밀하지는 않아요(coarse-grained): 보통 수십 킬로바이트 규모인 HTTP/2 흐름 제어 윈도우 한 개 분량의 안 읽힌 데이터가 쌓인 뒤에야 작동하고, 단일 읽지 않은 청크 이후에는 작동하지 않아요. 또한 이는 전송 계층이 보내는 것을 멈추는 것만 보장해요. 제공자의 백엔드가 전송 버퍼가 가득 찬 뒤 실제로 오디오 생성을 늦추는지, 아니면 서버 측에서 계속 버퍼링하는지는 제공자마다 다르며, 클라이언트 측 브리지가 제어하거나 검증할 수 있는 것이 아니에요.
| __ | 완전하고 테스트된 참조 구현인 FluxInputStream은 spring-ai-openai 모듈의 테스트 소스에 있는 OpenAiAudioSpeechModelIT에서 확인할 수 있어요. |
|---|
Configuration-Based Provider Selection
Spring 프로파일이나 프로퍼티를 사용해 제공자 간 전환을 해볼까요:
# application-openai.yml
spring:
ai:
model:
audio:
speech: openai
openai:
api-key: ***
audio:
speech:
options:
model: gpt-4o-mini-tts
voice: alloy
# application-elevenlabs.yml
spring:
ai:
model:
audio:
speech: elevenlabs
elevenlabs:
api-key: ${ELEV...KEY}
tts:
options:
model-id: eleven_turbo_v2_5
voice-id: your_voice_id
그런 다음 원하는 제공자를 활성화하면 돼요:
# Use OpenAI
java -jar app.jar --spring.profiles.active=openai
# Use ElevenLabs
java -jar app.jar --spring.profiles.active=elevenlabs
Using Portable Options
최대한의 이식성을 위해 공통 TextToSpeechOptions 인터페이스 메서드만 사용해볼게요:
@Service
public class PortableNarrationService {
private final TextToSpeechModel textToSpeechModel;
public PortableNarrationService(TextToSpeechModel textToSpeechModel) {
this.textToSpeechModel = textToSpeechModel;
}
public byte[] createPortableNarration(String text) {
// Use provider's default options for maximum portability
TextToSpeechOptions options = textToSpeechModel.getOptions();
TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, options);
TextToSpeechResponse response = textToSpeechModel.call(prompt);
return response.getResult().getOutput();
}
}
Working with Provider-Specific Features
제공자 고유 기능이 필요할 때도, 이식성 있는 코드베이스를 유지하면서 사용할 수 있어요:
@Service
public class FlexibleNarrationService {
private final TextToSpeechModel textToSpeechModel;
public FlexibleNarrationService(TextToSpeechModel textToSpeechModel) {
this.textToSpeechModel = textToSpeechModel;
}
public byte[] narrate(String text, TextToSpeechOptions baseOptions) {
TextToSpeechOptions options = baseOptions;
// Apply provider-specific optimizations if available
if (textToSpeechModel instanceof OpenAiAudioSpeechModel) {
options = OpenAiAudioSpeechOptions.builder()
.from(baseOptions)
.model("gpt-4o-tts") // OpenAI-specific: use high-quality model
.speed(1.0)
.build();
} else if (textToSpeechModel instanceof ElevenLabsTextToSpeechModel) {
// ElevenLabs-specific options could go here
}
TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, options);
TextToSpeechResponse response = textToSpeechModel.call(prompt);
return response.getResult().getOutput();
}
}
Best Practices for Portable Code
-
Depend on Interfaces : 항상 구체 구현 대신
TextToSpeechModel을 주입하세요 -
Use Common Options : 최대 이식성을 위해
TextToSpeechOptions인터페이스 메서드만 사용하세요 -
Handle Metadata Gracefully : 제공자마다 다른 메타데이터를 반환하니, 제네릭하게 처리하세요
-
Test with Multiple Providers : 최소 두 개의 TTS 제공자로 코드가 동작하는지 확인하세요
-
Document Provider Assumptions : 특정 제공자 동작에 의존한다면, 명확히 문서화하세요
Provider-Specific Features
공유 인터페이스가 이식성을 제공하는 동안, 각 제공자는 제공자별 옵션 클래스(예: OpenAiAudioSpeechOptions, ElevenLabsSpeechOptions)를 통해 고유 기능도 제공해요. 이 클래스들은 TextToSpeechOptions 인터페이스를 구현하면서 제공자별 기능을 추가해요.