텍스트-음성

텍스트-음성 (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

  1. Depend on Interfaces : 항상 구체 구현 대신 TextToSpeechModel을 주입하세요

  2. Use Common Options : 최대 이식성을 위해 TextToSpeechOptions 인터페이스 메서드만 사용하세요

  3. Handle Metadata Gracefully : 제공자마다 다른 메타데이터를 반환하니, 제네릭하게 처리하세요

  4. Test with Multiple Providers : 최소 두 개의 TTS 제공자로 코드가 동작하는지 확인하세요

  5. Document Provider Assumptions : 특정 제공자 동작에 의존한다면, 명확히 문서화하세요

Provider-Specific Features

공유 인터페이스가 이식성을 제공하는 동안, 각 제공자는 제공자별 옵션 클래스(예: OpenAiAudioSpeechOptions, ElevenLabsSpeechOptions)를 통해 고유 기능도 제공해요. 이 클래스들은 TextToSpeechOptions 인터페이스를 구현하면서 제공자별 기능을 추가해요.

더 알아보기 (Learn more)