refactor(openai): change voice parameter to string in OpenAI Audio Speech API (#2395)

This change modifies the voice parameter in OpenAI Audio Speech API from using the
Voice enum directly to using the string value of the enum. This provides more
flexibility for handling voice options, especially for custom voices or when voice
names come from configuration.

- Change voice parameter type from Voice enum to String
- Add overloaded methods to accept both enum and string values
- Update tests and documentation to reflect these changes

Signed-off-by: jonghoon park <dev@jonghoonpark.com>
This commit is contained in:
jonghoon park
2025-03-06 16:20:47 +09:00
committed by Christian Tzolov
parent 3fcb10a326
commit 14e7033a8e
10 changed files with 74 additions and 43 deletions

View File

@@ -29,6 +29,7 @@ import org.springframework.boot.context.properties.NestedConfigurationProperty;
*
* @author Ahmed Yousri
* @author Stefan Vassilev
* @author Jonghoon Park
*/
@ConfigurationProperties(OpenAiAudioSpeechProperties.CONFIG_PREFIX)
public class OpenAiAudioSpeechProperties extends OpenAiParentProperties {
@@ -39,7 +40,7 @@ public class OpenAiAudioSpeechProperties extends OpenAiParentProperties {
private static final Float SPEED = 1.0f;
private static final OpenAiAudioApi.SpeechRequest.Voice VOICE = OpenAiAudioApi.SpeechRequest.Voice.ALLOY;
private static final String VOICE = OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue();
private static final OpenAiAudioApi.SpeechRequest.AudioResponseFormat DEFAULT_RESPONSE_FORMAT = OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -40,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*
* @author Christian Tzolov
* @author Thomas Vitale
* @author Jonghoon Park
* @since 0.8.0
*/
public class OpenAiPropertiesTests {
@@ -177,7 +178,7 @@ public class OpenAiPropertiesTests {
assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_1");
assertThat(speechProperties.getOptions().getVoice())
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY);
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue());
assertThat(speechProperties.getOptions().getResponseFormat())
.isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3);
assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.75f);
@@ -205,7 +206,7 @@ public class OpenAiPropertiesTests {
assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_1");
assertThat(speechProperties.getOptions().getVoice())
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY);
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue());
assertThat(speechProperties.getOptions().getResponseFormat())
.isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3);
assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.75f);
@@ -237,7 +238,8 @@ public class OpenAiPropertiesTests {
assertThat(speechProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2");
assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_2");
assertThat(speechProperties.getOptions().getVoice()).isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ECHO);
assertThat(speechProperties.getOptions().getVoice())
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ECHO.getValue());
assertThat(speechProperties.getOptions().getResponseFormat())
.isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.OPUS);
assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.5f);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -42,6 +42,7 @@ import org.springframework.util.Assert;
* @author Ahmed Yousri
* @author Hyunjoon Choi
* @author Thomas Vitale
* @author Jonghoon Park
* @see OpenAiAudioApi
* @since 1.0.0-M1
*/
@@ -81,7 +82,7 @@ public class OpenAiAudioSpeechModel implements SpeechModel, StreamingSpeechModel
OpenAiAudioSpeechOptions.builder()
.model(OpenAiAudioApi.TtsModel.TTS_1.getValue())
.responseFormat(AudioResponseFormat.MP3)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue())
.speed(SPEED)
.build());
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -29,14 +29,15 @@ import org.springframework.ai.openai.api.OpenAiAudioApi.SpeechRequest.Voice;
* @author Ahmed Yousri
* @author Hyunjoon Choi
* @author Ilayaperumal Gopinathan
* @author Jonghoon Park
* @since 1.0.0-M1
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OpenAiAudioSpeechOptions implements ModelOptions {
/**
* ID of the model to use for generating the audio. One of the available TTS models:
* tts-1 or tts-1-hd.
* ID of the model to use for generating the audio. For OpenAI's TTS API, use one of
* the available models: tts-1 or tts-1-hd.
*/
@JsonProperty("model")
private String model;
@@ -48,11 +49,11 @@ public class OpenAiAudioSpeechOptions implements ModelOptions {
private String input;
/**
* The voice to use for synthesis. One of the available voices for the chosen model:
* 'alloy', 'echo', 'fable', 'onyx', 'nova', and 'shimmer'.
* The voice to use for synthesis. For OpenAI's TTS API, One of the available voices
* for the chosen model: 'alloy', 'echo', 'fable', 'onyx', 'nova', and 'shimmer'.
*/
@JsonProperty("voice")
private Voice voice;
private String voice;
/**
* The format of the audio output. Supported formats are mp3, opus, aac, and flac.
@@ -88,14 +89,18 @@ public class OpenAiAudioSpeechOptions implements ModelOptions {
this.input = input;
}
public Voice getVoice() {
public String getVoice() {
return this.voice;
}
public void setVoice(Voice voice) {
public void setVoice(String voice) {
this.voice = voice;
}
public void setVoice(Voice voice) {
this.voice = voice.getValue();
}
public AudioResponseFormat getResponseFormat() {
return this.responseFormat;
}
@@ -197,11 +202,16 @@ public class OpenAiAudioSpeechOptions implements ModelOptions {
return this;
}
public Builder voice(Voice voice) {
public Builder voice(String voice) {
this.options.voice = voice;
return this;
}
public Builder voice(Voice voice) {
this.options.voice = voice.getValue();
return this;
}
public Builder responseFormat(AudioResponseFormat responseFormat) {
this.options.responseFormat = responseFormat;
return this;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -47,6 +47,7 @@ import org.springframework.web.reactive.function.client.WebClient;
*
* @author Christian Tzolov
* @author Ilayaperumal Gopinathan
* @author Jonghoon Park
* @since 0.8.1
*/
public class OpenAiAudioApi {
@@ -330,7 +331,7 @@ public class OpenAiAudioApi {
// @formatter:off
@JsonProperty("model") String model,
@JsonProperty("input") String input,
@JsonProperty("voice") Voice voice,
@JsonProperty("voice") String voice,
@JsonProperty("response_format") AudioResponseFormat responseFormat,
@JsonProperty("speed") Float speed) {
// @formatter:on
@@ -419,7 +420,7 @@ public class OpenAiAudioApi {
private String input;
private Voice voice;
private String voice;
private AudioResponseFormat responseFormat = AudioResponseFormat.MP3;
@@ -435,11 +436,16 @@ public class OpenAiAudioApi {
return this;
}
public Builder voice(Voice voice) {
public Builder voice(String voice) {
this.voice = voice;
return this;
}
public Builder voice(Voice voice) {
this.voice = voice.getValue();
return this;
}
public Builder responseFormat(AudioResponseFormat responseFormat) {
this.responseFormat = responseFormat;
return this;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -37,6 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @author Jonghoon Park
*/
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class OpenAiAudioApiIT {
@@ -53,7 +54,7 @@ public class OpenAiAudioApiIT {
.createSpeech(SpeechRequest.builder()
.model(TtsModel.TTS_1_HD.getValue())
.input("Hello, my name is Chris and I love Spring A.I.")
.voice(Voice.ONYX)
.voice(Voice.ONYX.getValue())
.build())
.getBody();

View File

@@ -31,6 +31,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
/**
* @author Ilayaperumal Gopinathan
* @author Jonghoon Park
*/
@SpringBootTest(classes = OpenAiAudioModelNoOpApiKeysIT.Config.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
@@ -46,7 +47,7 @@ public class OpenAiAudioModelNoOpApiKeysIT {
.createSpeech(OpenAiAudioApi.SpeechRequest.builder()
.model(OpenAiAudioApi.TtsModel.TTS_1_HD.getValue())
.input("Hello, my name is Chris and I love Spring A.I.")
.voice(OpenAiAudioApi.SpeechRequest.Voice.ONYX)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ONYX.getValue())
.build())
.getBody();
}).isInstanceOf(NonTransientAiException.class);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 the original author or authors.
* Copyright 2023-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -33,6 +33,10 @@ import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ahmed Yousri
* @author Jonghoon Park
*/
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class OpenAiSpeechModelIT extends AbstractIT {
@@ -57,7 +61,7 @@ class OpenAiSpeechModelIT extends AbstractIT {
@Test
void shouldGenerateNonEmptyMp3AudioFromSpeechPrompt() {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue())
.speed(SPEED)
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.model(OpenAiAudioApi.TtsModel.TTS_1.value)
@@ -93,7 +97,7 @@ class OpenAiSpeechModelIT extends AbstractIT {
@Test
void speechRateLimitTest() {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue())
.speed(SPEED)
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.model(OpenAiAudioApi.TtsModel.TTS_1.value)
@@ -113,7 +117,7 @@ class OpenAiSpeechModelIT extends AbstractIT {
void shouldStreamNonEmptyResponsesForValidSpeechPrompts() {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue())
.speed(SPEED)
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.model(OpenAiAudioApi.TtsModel.TTS_1.value)
@@ -135,7 +139,7 @@ class OpenAiSpeechModelIT extends AbstractIT {
@ValueSource(strings = { "alloy", "echo", "fable", "onyx", "nova", "shimmer", "sage", "coral", "ash" })
void speechVoicesTest(String voice) {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.valueOf(voice.toUpperCase()))
.voice(voice)
.speed(SPEED)
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.model(OpenAiAudioApi.TtsModel.TTS_1.value)

View File

@@ -46,6 +46,7 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
/**
* @author Ahmed Yousri
* @author Jonghoon Park
*/
@RestClientTest(OpenAiSpeechModelWithSpeechResponseMetadataTests.Config.class)
public class OpenAiSpeechModelWithSpeechResponseMetadataTests {
@@ -71,7 +72,7 @@ public class OpenAiSpeechModelWithSpeechResponseMetadataTests {
prepareMock();
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY.getValue())
.speed(SPEED)
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.model(OpenAiAudioApi.TtsModel.TTS_1.value)

View File

@@ -85,8 +85,8 @@ The prefix `spring.ai.openai.audio.speech` is used as the property prefix that l
| spring.ai.openai.audio.speech.api-key | The API Key | -
| spring.ai.openai.audio.speech.organization-id | Optionally you can specify which organization used for an API request. | -
| spring.ai.openai.audio.speech.project-id | Optionally, you can specify which project is used for an API request. | -
| spring.ai.openai.audio.speech.options.model | ID of the model to use. Only tts-1 is currently available. | tts-1
| spring.ai.openai.audio.speech.options.voice | The voice to use for the TTS output. Available options are: alloy, echo, fable, onyx, nova, and shimmer. | alloy
| spring.ai.openai.audio.speech.options.model | ID of the model to use for generating the audio. For OpenAI's TTS API, use one of the available models: tts-1 or tts-1-hd. | tts-1
| spring.ai.openai.audio.speech.options.voice | The voice to use for synthesis. For OpenAI's TTS API, One of the available voices for the chosen model: alloy, echo, fable, onyx, nova, and shimmer. | alloy
| spring.ai.openai.audio.speech.options.response-format | The format of the audio output. Supported formats are mp3, opus, aac, flac, wav, and pcm. | mp3
| spring.ai.openai.audio.speech.options.speed | The speed of the voice synthesis. The acceptable range is from 0.25 (slowest) to 4.0 (fastest). | 1.0
|====
@@ -113,8 +113,8 @@ OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.speed(1.0f)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", this.speechOptions);
SpeechResponse response = openAiAudioSpeechModel.call(this.speechPrompt);
SpeechPrompt speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", speechOptions);
SpeechResponse response = openAiAudioSpeechModel.call(speechPrompt);
----
== Manual Configuration
@@ -144,9 +144,11 @@ Next, create an `OpenAiAudioSpeechModel`:
[source,java]
----
var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY"));
var openAiAudioApi = new OpenAiAudioApi()
.apiKey(System.getenv("OPENAI_API_KEY"))
.build();
var openAiAudioSpeechModel = new OpenAiAudioSpeechModel(this.openAiAudioApi);
var openAiAudioSpeechModel = new OpenAiAudioSpeechModel(openAiAudioApi);
var speechOptions = OpenAiAudioSpeechOptions.builder()
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
@@ -154,13 +156,13 @@ var speechOptions = OpenAiAudioSpeechOptions.builder()
.model(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
var speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", this.speechOptions);
SpeechResponse response = this.openAiAudioSpeechModel.call(this.speechPrompt);
var speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", speechOptions);
SpeechResponse response = openAiAudioSpeechModel.call(speechPrompt);
// Accessing metadata (rate limit info)
OpenAiAudioSpeechResponseMetadata metadata = this.response.getMetadata();
OpenAiAudioSpeechResponseMetadata metadata = response.getMetadata();
byte[] responseAsBytes = this.response.getResult().getOutput();
byte[] responseAsBytes = response.getResult().getOutput();
----
== Streaming Real-time Audio
@@ -169,9 +171,11 @@ The Speech API provides support for real-time audio streaming using chunk transf
[source,java]
----
var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY"));
var openAiAudioApi = new OpenAiAudioApi()
.apiKey(System.getenv("OPENAI_API_KEY"))
.build();
var openAiAudioSpeechModel = new OpenAiAudioSpeechModel(this.openAiAudioApi);
var openAiAudioSpeechModel = new OpenAiAudioSpeechModel(openAiAudioApi);
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
@@ -180,9 +184,9 @@ OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.model(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!", this.speechOptions);
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!", speechOptions);
Flux<SpeechResponse> responseStream = this.openAiAudioSpeechModel.stream(this.speechPrompt);
Flux<SpeechResponse> responseStream = openAiAudioSpeechModel.stream(speechPrompt);
----
== Example Code