Added support for OpenAI Text to Audio (Speech API )

* Added documentation
This commit is contained in:
ahmed
2024-03-06 21:58:20 -05:00
committed by Mark Pollack
parent 6ba897ef76
commit 766b420f98
24 changed files with 1600 additions and 2 deletions

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.metadata.RateLimit;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.api.OpenAiAudioApi.SpeechRequest.AudioResponseFormat;
import org.springframework.ai.openai.api.common.OpenAiApiException;
import org.springframework.ai.openai.audio.speech.*;
import org.springframework.ai.openai.metadata.audio.OpenAiAudioSpeechResponseMetadata;
import org.springframework.ai.openai.metadata.support.OpenAiResponseHeaderExtractor;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import java.time.Duration;
/**
* OpenAI audio speech client implementation for backed by {@link OpenAiAudioApi}.
*
* @author Ahmed Yousri
* @see OpenAiAudioApi
* @since 1.0.0-M1
*/
public class OpenAiAudioSpeechClient implements SpeechClient, StreamingSpeechClient {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final OpenAiAudioSpeechOptions defaultOptions;
private static final Float SPEED = 1.0f;
public final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(10)
.retryOn(OpenAiApiException.class)
.exponentialBackoff(Duration.ofMillis(2000), 5, Duration.ofMillis(3 * 60000))
.build();
private final OpenAiAudioApi audioApi;
/**
* Initializes a new instance of the OpenAiAudioSpeechClient class with the provided
* OpenAiAudioApi. It uses the model tts-1, response format mp3, voice alloy, and the
* default speed of 1.0.
* @param audioApi The OpenAiAudioApi to use for speech synthesis.
*/
public OpenAiAudioSpeechClient(OpenAiAudioApi audioApi) {
this(audioApi,
OpenAiAudioSpeechOptions.builder()
.withModel(OpenAiAudioApi.TtsModel.TTS_1.getValue())
.withResponseFormat(AudioResponseFormat.MP3)
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withSpeed(SPEED)
.build());
}
/**
* Initializes a new instance of the OpenAiAudioSpeechClient class with the provided
* OpenAiAudioApi and options.
* @param audioApi The OpenAiAudioApi to use for speech synthesis.
* @param options The OpenAiAudioSpeechOptions containing the speech synthesis
* options.
*/
public OpenAiAudioSpeechClient(OpenAiAudioApi audioApi, OpenAiAudioSpeechOptions options) {
Assert.notNull(audioApi, "OpenAiAudioApi must not be null");
Assert.notNull(options, "OpenAiSpeechOptions must not be null");
this.audioApi = audioApi;
this.defaultOptions = options;
}
@Override
public byte[] call(String text) {
SpeechPrompt speechRequest = new SpeechPrompt(text);
return call(speechRequest).getResult().getOutput();
}
@Override
public SpeechResponse call(SpeechPrompt speechPrompt) {
return this.retryTemplate.execute(ctx -> {
OpenAiAudioApi.SpeechRequest speechRequest = createRequestBody(speechPrompt);
ResponseEntity<byte[]> speechEntity = this.audioApi.createSpeech(speechRequest);
var speech = speechEntity.getBody();
if (speech == null) {
logger.warn("No speech response returned for speechRequest: {}", speechRequest);
return new SpeechResponse(new Speech(new byte[0]));
}
RateLimit rateLimits = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(speechEntity);
return new SpeechResponse(new Speech(speech), new OpenAiAudioSpeechResponseMetadata(rateLimits));
});
}
/**
* Streams the audio response for the given speech prompt.
* @param prompt The speech prompt containing the text and options for speech
* synthesis.
* @return A Flux of SpeechResponse objects containing the streamed audio and
* metadata.
*/
@Override
public Flux<SpeechResponse> stream(SpeechPrompt prompt) {
return this.audioApi.stream(this.createRequestBody(prompt))
.map(entity -> new SpeechResponse(new Speech(entity.getBody()), new OpenAiAudioSpeechResponseMetadata(
OpenAiResponseHeaderExtractor.extractAiResponseHeaders(entity))));
}
private OpenAiAudioApi.SpeechRequest createRequestBody(SpeechPrompt request) {
OpenAiAudioSpeechOptions options = this.defaultOptions;
if (request.getOptions() != null) {
if (request.getOptions() instanceof OpenAiAudioSpeechOptions runtimeOptions) {
options = this.merge(options, runtimeOptions);
}
else {
throw new IllegalArgumentException("Prompt options are not of type SpeechOptions: "
+ request.getOptions().getClass().getSimpleName());
}
}
String input = StringUtils.isNotBlank(options.getInput()) ? options.getInput()
: request.getInstructions().getText();
OpenAiAudioApi.SpeechRequest.Builder requestBuilder = OpenAiAudioApi.SpeechRequest.builder()
.withModel(options.getModel())
.withInput(input)
.withVoice(options.getVoice())
.withResponseFormat(options.getResponseFormat())
.withSpeed(options.getSpeed());
return requestBuilder.build();
}
private OpenAiAudioSpeechOptions merge(OpenAiAudioSpeechOptions source, OpenAiAudioSpeechOptions target) {
OpenAiAudioSpeechOptions.Builder mergedBuilder = OpenAiAudioSpeechOptions.builder();
mergedBuilder.withModel(source.getModel() != null ? source.getModel() : target.getModel());
mergedBuilder.withInput(source.getInput() != null ? source.getInput() : target.getInput());
mergedBuilder.withVoice(source.getVoice() != null ? source.getVoice() : target.getVoice());
mergedBuilder.withResponseFormat(
source.getResponseFormat() != null ? source.getResponseFormat() : target.getResponseFormat());
mergedBuilder.withSpeed(source.getSpeed() != null ? source.getSpeed() : target.getSpeed());
return mergedBuilder.build();
}
}

View File

@@ -0,0 +1,205 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.model.ModelOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi.SpeechRequest.AudioResponseFormat;
import org.springframework.ai.openai.api.OpenAiAudioApi.SpeechRequest.Voice;
/**
* Options for OpenAI text to audio - speech synthesis.
*
* @author Ahmed Yousri
* @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.
*/
@JsonProperty("model")
private String model;
/**
* The input text to synthesize. Must be at most 4096 tokens long.
*/
@JsonProperty("input")
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'.
*/
@JsonProperty("voice")
private Voice voice;
/**
* The format of the audio output. Supported formats are mp3, opus, aac, and flac.
* Defaults to mp3. Defaults to mp3
*/
@JsonProperty("response_format")
private AudioResponseFormat responseFormat;
/**
* The speed of the voice synthesis. The acceptable range is from 0.0 (slowest) to 1.0
* (fastest). Defaults to 1
*/
@JsonProperty("speed")
private Float speed;
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final OpenAiAudioSpeechOptions options = new OpenAiAudioSpeechOptions();
public Builder withModel(String model) {
options.model = model;
return this;
}
public Builder withInput(String input) {
options.input = input;
return this;
}
public Builder withVoice(Voice voice) {
options.voice = voice;
return this;
}
public Builder withResponseFormat(AudioResponseFormat responseFormat) {
options.responseFormat = responseFormat;
return this;
}
public Builder withSpeed(Float speed) {
options.speed = speed;
return this;
}
public OpenAiAudioSpeechOptions build() {
return options;
}
}
public String getModel() {
return model;
}
public String getInput() {
return input;
}
public Voice getVoice() {
return voice;
}
public AudioResponseFormat getResponseFormat() {
return responseFormat;
}
public Float getSpeed() {
return speed;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((model == null) ? 0 : model.hashCode());
result = prime * result + ((input == null) ? 0 : input.hashCode());
result = prime * result + ((voice == null) ? 0 : voice.hashCode());
result = prime * result + ((responseFormat == null) ? 0 : responseFormat.hashCode());
result = prime * result + ((speed == null) ? 0 : speed.hashCode());
return result;
}
public void setModel(String model) {
this.model = model;
}
public void setInput(String input) {
this.input = input;
}
public void setVoice(Voice voice) {
this.voice = voice;
}
public void setResponseFormat(AudioResponseFormat responseFormat) {
this.responseFormat = responseFormat;
}
public void setSpeed(Float speed) {
this.speed = speed;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
OpenAiAudioSpeechOptions other = (OpenAiAudioSpeechOptions) obj;
if (model == null) {
if (other.model != null)
return false;
}
else if (!model.equals(other.model))
return false;
if (input == null) {
if (other.input != null)
return false;
}
else if (!input.equals(other.input))
return false;
if (voice == null) {
if (other.voice != null)
return false;
}
else if (!voice.equals(other.voice))
return false;
if (responseFormat == null) {
if (other.responseFormat != null)
return false;
}
else if (!responseFormat.equals(other.responseFormat))
return false;
if (speed == null) {
return other.speed == null;
}
else
return speed.equals(other.speed);
}
@Override
public String toString() {
return "OpenAiAudioSpeechOptions{" + "model='" + model + '\'' + ", input='" + input + '\'' + ", voice='" + voice
+ '\'' + ", responseFormat='" + responseFormat + '\'' + ", speed=" + speed + '}';
}
}

View File

@@ -51,6 +51,8 @@ import org.springframework.util.Assert;
/**
* OpenAI audio transcription client implementation for backed by {@link OpenAiAudioApi}.
* You provide as input the audio file you want to transcribe and the desired output file
* format of the transcription of the audio.
*
* @author Michael Lavelle
* @author Christian Tzolov

View File

@@ -23,12 +23,17 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Turn audio into text or text into audio. Based on
@@ -41,12 +46,15 @@ public class OpenAiAudioApi {
private final RestClient restClient;
private final WebClient webClient;
/**
* Create an new audio api.
* @param openAiToken OpenAI apiKey.
*/
public OpenAiAudioApi(String openAiToken) {
this(ApiUtils.DEFAULT_BASE_URL, openAiToken, RestClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
this(ApiUtils.DEFAULT_BASE_URL, openAiToken, RestClient.builder(), WebClient.builder(),
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
/**
@@ -62,6 +70,30 @@ public class OpenAiAudioApi {
this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders(headers -> {
headers.setBearerAuth(openAiToken);
}).defaultStatusHandler(responseErrorHandler).build();
this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(headers -> {
headers.setBearerAuth(openAiToken);
}).defaultHeaders(ApiUtils.getJsonContentHeaders(openAiToken)).build();
}
/**
* Create an new chat completion api.
* @param baseUrl api base URL.
* @param openAiToken OpenAI apiKey.
* @param restClientBuilder RestClient builder.
* @param webClientBuilder WebClient builder.
* @param responseErrorHandler Response error handler.
*/
public OpenAiAudioApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder,
WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) {
this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders(headers -> {
headers.setBearerAuth(openAiToken);
}).defaultStatusHandler(responseErrorHandler).build();
this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(headers -> {
headers.setBearerAuth(openAiToken);
}).defaultHeaders(ApiUtils.getJsonContentHeaders(openAiToken)).build();
}
/**
@@ -570,6 +602,30 @@ public class OpenAiAudioApi {
return this.restClient.post().uri("/v1/audio/speech").body(requestBody).retrieve().toEntity(byte[].class);
}
/**
* Streams audio generated from the input text.
*
* This method sends a POST request to the OpenAI API to generate audio from the
* provided text. The audio is streamed back as a Flux of ResponseEntity objects, each
* containing a byte array of the audio data.
* @param requestBody The request body containing the details for the audio
* generation, such as the input text, model, voice, and response format.
* @return A Flux of ResponseEntity objects, each containing a byte array of the audio
* data.
*/
public Flux<ResponseEntity<byte[]>> stream(SpeechRequest requestBody) {
return webClient.post()
.uri("/v1/audio/speech")
.body(Mono.just(requestBody), SpeechRequest.class)
.accept(MediaType.APPLICATION_OCTET_STREAM)
.exchangeToFlux(clientResponse -> {
HttpHeaders headers = clientResponse.headers().asHttpHeaders();
return clientResponse.bodyToFlux(byte[].class)
.map(bytes -> ResponseEntity.ok().headers(headers).body(bytes));
});
}
/**
* Transcribes audio into the input language.
* @param requestBody The request body.

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.springframework.ai.model.ModelResult;
import org.springframework.ai.openai.metadata.audio.OpenAiAudioSpeechMetadata;
import org.springframework.lang.Nullable;
import java.util.Arrays;
import java.util.Objects;
/**
* The Speech class represents the result of speech synthesis from an AI model. It
* implements the ModelResult interface with the output type of byte array.
*
* @author Ahmed Yousri
* @since 1.0.0-M1
*/
public class Speech implements ModelResult<byte[]> {
private final byte[] audio;
private OpenAiAudioSpeechMetadata speechMetadata;
public Speech(byte[] audio) {
this.audio = audio;
}
@Override
public byte[] getOutput() {
return this.audio;
}
@Override
public OpenAiAudioSpeechMetadata getMetadata() {
return speechMetadata != null ? speechMetadata : OpenAiAudioSpeechMetadata.NULL;
}
public Speech withSpeechMetadata(@Nullable OpenAiAudioSpeechMetadata speechMetadata) {
this.speechMetadata = speechMetadata;
return this;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Speech that))
return false;
return Arrays.equals(audio, that.audio) && Objects.equals(speechMetadata, that.speechMetadata);
}
@Override
public int hashCode() {
return Objects.hash(Arrays.hashCode(audio), speechMetadata);
}
@Override
public String toString() {
return "Speech{" + "text=" + audio + ", speechMetadata=" + speechMetadata + '}';
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.springframework.ai.model.ModelClient;
/**
* The {@link SpeechClient} interface provides a way to interact with the OpenAI
* Text-to-Speech (TTS) API. It allows you to convert text input into lifelike spoken
* audio.
*
* @author Ahmed Yousri
* @since 1.0.0-M1
*/
@FunctionalInterface
public interface SpeechClient extends ModelClient<SpeechPrompt, SpeechResponse> {
/**
* Generates spoken audio from the provided text message.
* @param message the text message to be converted to audio
* @return the resulting audio bytes
*/
default byte[] call(String message) {
SpeechPrompt prompt = new SpeechPrompt(message);
return call(prompt).getResult().getOutput();
}
/**
* Sends a speech request to the OpenAI TTS API and returns the resulting speech
* response.
* @param request the speech prompt containing the input text and other parameters
* @return the speech response containing the generated audio
*/
SpeechResponse call(SpeechPrompt request);
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import java.util.Objects;
/**
* The {@link SpeechMessage} class represents a single text message to be converted to
* speech by the OpenAI TTS API.
*
* @author Ahmed Yousri
* @since 1.0.0-M1
*/
public class SpeechMessage {
private String text;
/**
* Constructs a new {@link SpeechMessage} object with the given text.
* @param text the text to be converted to speech
*/
public SpeechMessage(String text) {
this.text = text;
}
/**
* Returns the text of this speech message.
* @return the text of this speech message
*/
public String getText() {
return text;
}
/**
* Sets the text of this speech message.
* @param text the new text for this speech message
*/
public void setText(String text) {
this.text = text;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof SpeechMessage that))
return false;
return Objects.equals(text, that.text);
}
@Override
public int hashCode() {
return Objects.hash(text);
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.springframework.ai.model.ModelOptions;
import org.springframework.ai.model.ModelRequest;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* The {@link SpeechPrompt} class represents a request to the OpenAI Text-to-Speech (TTS)
* API. It contains a list of {@link SpeechMessage} objects, each representing a piece of
* text to be converted to speech.
*
* @author Ahmed Yousri
* @since 1.0.0-M1
*/
public class SpeechPrompt implements ModelRequest<SpeechMessage> {
private OpenAiAudioSpeechOptions speechOptions;
private final SpeechMessage message;
public SpeechPrompt(String instructions) {
this(new SpeechMessage(instructions), OpenAiAudioSpeechOptions.builder().build());
}
public SpeechPrompt(String instructions, OpenAiAudioSpeechOptions speechOptions) {
this(new SpeechMessage(instructions), speechOptions);
}
public SpeechPrompt(SpeechMessage speechMessage) {
this(speechMessage, OpenAiAudioSpeechOptions.builder().build());
}
public SpeechPrompt(SpeechMessage speechMessage, OpenAiAudioSpeechOptions speechOptions) {
this.message = speechMessage;
this.speechOptions = speechOptions;
}
@Override
public SpeechMessage getInstructions() {
return this.message;
}
@Override
public ModelOptions getOptions() {
return speechOptions;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof SpeechPrompt that))
return false;
return Objects.equals(speechOptions, that.speechOptions) && Objects.equals(message, that.message);
}
@Override
public int hashCode() {
return Objects.hash(speechOptions, message);
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.springframework.ai.model.ModelResponse;
import org.springframework.ai.openai.metadata.audio.OpenAiAudioSpeechResponseMetadata;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Creates a new instance of SpeechResponse with the given speech result.
*
* @author Ahmed Yousri
* @since 1.0.0-M1
*/
public class SpeechResponse implements ModelResponse<Speech> {
private final Speech speech;
private final OpenAiAudioSpeechResponseMetadata speechResponseMetadata;
/**
* Creates a new instance of SpeechResponse with the given speech result.
* @param speech the speech result to be set in the SpeechResponse
* @see Speech
*/
public SpeechResponse(Speech speech) {
this(speech, OpenAiAudioSpeechResponseMetadata.NULL);
}
/**
* Creates a new instance of SpeechResponse with the given speech result and speech
* response metadata.
* @param speech the speech result to be set in the SpeechResponse
* @param speechResponseMetadata the speech response metadata to be set in the
* SpeechResponse
* @see Speech
* @see OpenAiAudioSpeechResponseMetadata
*/
public SpeechResponse(Speech speech, OpenAiAudioSpeechResponseMetadata speechResponseMetadata) {
this.speech = speech;
this.speechResponseMetadata = speechResponseMetadata;
}
@Override
public Speech getResult() {
return speech;
}
@Override
public List<Speech> getResults() {
return Collections.singletonList(speech);
}
@Override
public OpenAiAudioSpeechResponseMetadata getMetadata() {
return speechResponseMetadata;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof SpeechResponse that))
return false;
return Objects.equals(speech, that.speech)
&& Objects.equals(speechResponseMetadata, that.speechResponseMetadata);
}
@Override
public int hashCode() {
return Objects.hash(speech, speechResponseMetadata);
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.springframework.ai.model.StreamingModelClient;
import reactor.core.publisher.Flux;
/**
* The {@link StreamingSpeechClient} interface provides a way to interact with the OpenAI
* Text-to-Speech (TTS) API using a streaming approach, allowing you to receive the
* generated audio in a real-time fashion.
*
* @author Ahmed Yousri
* @since 1.0.0-M1
*/
@FunctionalInterface
public interface StreamingSpeechClient extends StreamingModelClient<SpeechPrompt, SpeechResponse> {
/**
* Generates a stream of audio bytes from the provided text message.
* @param message the text message to be converted to audio
* @return a Flux of audio bytes representing the generated speech
*/
default Flux<byte[]> stream(String message) {
SpeechPrompt prompt = new SpeechPrompt(message);
return stream(prompt).map(SpeechResponse::getResult).map(Speech::getOutput);
}
/**
* Sends a speech request to the OpenAI TTS API and returns a stream of the resulting
* speech responses.
* @param prompt the speech prompt containing the input text and other parameters
* @return a Flux of speech responses, each containing a portion of the generated
* audio
*/
@Override
Flux<SpeechResponse> stream(SpeechPrompt prompt);
}

View File

@@ -20,6 +20,10 @@ import org.springframework.ai.model.ModelRequest;
import org.springframework.core.io.Resource;
/**
* Represents an audio transcription prompt for an AI model. It implements the
* {@link ModelRequest} interface and provides the necessary information required to
* interact with an AI model, including the audio resource and model options.
*
* @author Michael Lavelle
* @since 0.8.1
*/
@@ -29,10 +33,23 @@ public class AudioTranscriptionPrompt implements ModelRequest<Resource> {
private ModelOptions modelOptions;
/**
* Construct a new AudioTranscriptionPrompt given the resource representing the audio
* file. The following input file types are supported: mp3, mp4, mpeg, mpga, m4a, wav,
* and webm.
* @param audioResource resource of the audio file.
*/
public AudioTranscriptionPrompt(Resource audioResource) {
this.audioResource = audioResource;
}
/**
* Construct a new AudioTranscriptionPrompt given the resource representing the audio
* file. The following input file types are supported: mp3, mp4, mpeg, mpga, m4a, wav,
* and webm.
* @param audioResource resource of the audio file.
* @param modelOptions
*/
public AudioTranscriptionPrompt(Resource audioResource, ModelOptions modelOptions) {
this.audioResource = audioResource;
this.modelOptions = modelOptions;

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.metadata.audio;
import org.springframework.ai.model.ResultMetadata;
public interface OpenAiAudioSpeechMetadata extends ResultMetadata {
OpenAiAudioSpeechMetadata NULL = OpenAiAudioSpeechMetadata.create();
/**
* Factory method used to construct a new {@link OpenAiAudioSpeechMetadata}
* @return a new {@link OpenAiAudioSpeechMetadata}
*/
static OpenAiAudioSpeechMetadata create() {
return new OpenAiAudioSpeechMetadata() {
};
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.metadata.audio;
import org.springframework.ai.chat.metadata.EmptyRateLimit;
import org.springframework.ai.chat.metadata.RateLimit;
import org.springframework.ai.model.ResponseMetadata;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Audio speech metadata implementation for {@literal OpenAI}.
*
* @author Ahmed Yousri
* @see RateLimit
*/
public class OpenAiAudioSpeechResponseMetadata implements ResponseMetadata {
protected static final String AI_METADATA_STRING = "{ @type: %1$s, requestsLimit: %2$s }";
public static final OpenAiAudioSpeechResponseMetadata NULL = new OpenAiAudioSpeechResponseMetadata() {
};
public static OpenAiAudioSpeechResponseMetadata from(OpenAiAudioApi.StructuredResponse result) {
Assert.notNull(result, "OpenAI speech must not be null");
OpenAiAudioSpeechResponseMetadata speechResponseMetadata = new OpenAiAudioSpeechResponseMetadata();
return speechResponseMetadata;
}
public static OpenAiAudioSpeechResponseMetadata from(String result) {
Assert.notNull(result, "OpenAI speech must not be null");
OpenAiAudioSpeechResponseMetadata speechResponseMetadata = new OpenAiAudioSpeechResponseMetadata();
return speechResponseMetadata;
}
@Nullable
private RateLimit rateLimit;
public OpenAiAudioSpeechResponseMetadata() {
this(null);
}
public OpenAiAudioSpeechResponseMetadata(@Nullable RateLimit rateLimit) {
this.rateLimit = rateLimit;
}
@Nullable
public RateLimit getRateLimit() {
RateLimit rateLimit = this.rateLimit;
return rateLimit != null ? rateLimit : new EmptyRateLimit();
}
public OpenAiAudioSpeechResponseMetadata withRateLimit(RateLimit rateLimit) {
this.rateLimit = rateLimit;
return this;
}
@Override
public String toString() {
return AI_METADATA_STRING.formatted(getClass().getName(), getRateLimit());
}
}

View File

@@ -62,6 +62,12 @@ public class OpenAiTestConfiguration {
return openAiTranscriptionClient;
}
@Bean
public OpenAiAudioSpeechClient openAiAudioSpeechClient(OpenAiAudioApi api) {
OpenAiAudioSpeechClient openAiAudioSpeechClient = new OpenAiAudioSpeechClient(api);
return openAiAudioSpeechClient;
}
@Bean
public OpenAiImageClient openAiImageClient(OpenAiImageApi imageApi) {
OpenAiImageClient openAiImageClient = new OpenAiImageClient(imageApi);

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.metadata.audio.OpenAiAudioSpeechResponseMetadata;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.boot.test.context.SpringBootTest;
import reactor.core.publisher.Flux;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class OpenAiSpeechClientIT extends AbstractIT {
private static final Float SPEED = 1.0f;
@Test
void shouldSuccessfullyStreamAudioBytesForEmptyMessage() {
Flux<byte[]> response = openAiAudioSpeechClient
.stream("Today is a wonderful day to build something people love!");
assertThat(response).isNotNull();
assertThat(response.collectList().block()).isNotNull();
System.out.println(response.collectList().block());
}
@Test
void shouldProduceAudioBytesDirectlyFromMessage() {
byte[] audioBytes = openAiAudioSpeechClient.call("Today is a wonderful day to build something people love!");
assertThat(audioBytes).hasSizeGreaterThan(0);
}
@Test
void shouldGenerateNonEmptyMp3AudioFromSpeechPrompt() {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withSpeed(SPEED)
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withModel(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!",
speechOptions);
SpeechResponse response = openAiAudioSpeechClient.call(speechPrompt);
byte[] audioBytes = response.getResult().getOutput();
assertThat(response.getResults()).hasSize(1);
assertThat(response.getResults().get(0).getOutput()).isNotEmpty();
assertThat(audioBytes).hasSizeGreaterThan(0);
}
@Test
void speechRateLimitTest() {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withSpeed(SPEED)
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withModel(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!",
speechOptions);
SpeechResponse response = openAiAudioSpeechClient.call(speechPrompt);
OpenAiAudioSpeechResponseMetadata metadata = response.getMetadata();
assertThat(metadata).isNotNull();
assertThat(metadata.getRateLimit()).isNotNull();
assertThat(metadata.getRateLimit().getRequestsLimit()).isPositive();
assertThat(metadata.getRateLimit().getRequestsLimit()).isPositive();
}
@Test
void shouldStreamNonEmptyResponsesForValidSpeechPrompts() {
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withSpeed(SPEED)
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withModel(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!",
speechOptions);
Flux<SpeechResponse> responseFlux = openAiAudioSpeechClient.stream(speechPrompt);
assertThat(responseFlux).isNotNull();
List<SpeechResponse> responses = responseFlux.collectList().block();
assertThat(responses).isNotNull();
responses.forEach(response -> {
System.out.println("Audio data chunk size: " + response.getResult().getOutput().length);
assertThat(response.getResult().getOutput()).isNotEmpty();
});
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.audio.speech;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.OpenAiAudioSpeechClient;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.metadata.audio.OpenAiAudioSpeechResponseMetadata;
import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* @author Ahmed Yousri
*/
@RestClientTest(OpenAiSpeechClientWithSpeechResponseMetadataTests.Config.class)
public class OpenAiSpeechClientWithSpeechResponseMetadataTests {
private static String TEST_API_KEY = "sk-1234567890";
private static final Float SPEED = 1.0f;
@Autowired
private OpenAiAudioSpeechClient openAiSpeechClient;
@Autowired
private MockRestServiceServer server;
@AfterEach
void resetMockServer() {
server.reset();
}
@Test
void aiResponseContainsImageResponseMetadata() {
prepareMock();
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withSpeed(SPEED)
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withModel(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!",
speechOptions);
SpeechResponse response = openAiSpeechClient.call(speechPrompt);
byte[] audioBytes = response.getResult().getOutput();
assertThat(audioBytes).hasSizeGreaterThan(0);
OpenAiAudioSpeechResponseMetadata speechResponseMetadata = response.getMetadata();
assertThat(speechResponseMetadata).isNotNull();
var requestLimit = speechResponseMetadata.getRateLimit();
Long requestsLimit = requestLimit.getRequestsLimit();
Long tokensLimit = requestLimit.getTokensLimit();
Long tokensRemaining = requestLimit.getTokensRemaining();
Long requestsRemaining = requestLimit.getRequestsRemaining();
Duration requestsReset = requestLimit.getRequestsReset();
assertThat(requestsLimit).isNotNull();
assertThat(requestsLimit).isEqualTo(4000L);
assertThat(tokensLimit).isEqualTo(725000L);
assertThat(tokensRemaining).isEqualTo(112358L);
assertThat(requestsRemaining).isEqualTo(999L);
assertThat(requestsReset).isEqualTo(Duration.parse("PT64H15M29S"));
}
private void prepareMock() {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set(OpenAiApiResponseHeaders.REQUESTS_LIMIT_HEADER.getName(), "4000");
httpHeaders.set(OpenAiApiResponseHeaders.REQUESTS_REMAINING_HEADER.getName(), "999");
httpHeaders.set(OpenAiApiResponseHeaders.REQUESTS_RESET_HEADER.getName(), "2d16h15m29s");
httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_LIMIT_HEADER.getName(), "725000");
httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_REMAINING_HEADER.getName(), "112358");
httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_RESET_HEADER.getName(), "27h55s451ms");
httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
server.expect(requestTo("/v1/audio/speech"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + TEST_API_KEY))
.andRespond(withSuccess("Audio bytes as string", MediaType.APPLICATION_OCTET_STREAM).headers(httpHeaders));
}
@SpringBootConfiguration
static class Config {
@Bean
public OpenAiAudioSpeechClient openAiAudioSpeechClient(OpenAiAudioApi openAiAudioApi) {
return new OpenAiAudioSpeechClient(openAiAudioApi);
}
@Bean
public OpenAiAudioApi openAiAudioApi(RestClient.Builder builder) {
return new OpenAiAudioApi("", TEST_API_KEY, builder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.image.ImageClient;
import org.springframework.ai.openai.OpenAiAudioSpeechClient;
import org.springframework.ai.openai.OpenAiAudioTranscriptionClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -47,6 +48,9 @@ public abstract class AbstractIT {
@Autowired
protected OpenAiAudioTranscriptionClient openAiTranscriptionClient;
@Autowired
protected OpenAiAudioSpeechClient openAiAudioSpeechClient;
@Autowired
protected ImageClient openaiImageClient;

View File

@@ -40,6 +40,8 @@
*** xref:api/image/stabilityai-image.adoc[Stability]
** xref:api/transcriptions.adoc[]
*** xref:api/transcriptions/openai-transcriptions.adoc[OpenAI]
** xref:api/speech.adoc[]
*** xref:api/speech/openai-speech.adoc[OpenAI]
** xref:api/vectordbs.adoc[]
*** xref:api/vectordbs/azure.adoc[]
*** xref:api/vectordbs/chroma.adoc[]

View File

@@ -0,0 +1,5 @@
[[Speech]]
= Speech API
Spring AI provides support for OpenAI's Speech API.
When additional providers for Speech are implemented, a common `SpeechClient` and `StreamingSpeechClient` interface will be extracted.

View File

@@ -0,0 +1,144 @@
= OpenAI Text-to-Speech (TTS) Integration
== Introduction
The Audio API provides a speech endpoint based on OpenAI's TTS (text-to-speech) model, enabling users to:
- Narrate a written blog post.
- Produce spoken audio in multiple languages.
- Give real-time audio output using streaming.
== Prerequisites
. Create an OpenAI account and obtain an API key. You can sign up at the https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page].
. Add the `spring-ai-openai` dependency to your project's build file. For more information, refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section.
== Auto-configuration
Spring AI provides Spring Boot auto-configuration for the OpenAI Text-to-Speech Client.
To enable it add the following dependency to your project's Maven `pom.xml` file:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file:
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
=== TTS Properties
The prefix `spring.ai.openai.audio.speech` is used as the property prefix that lets you configure the OpenAI Text-to-Speech client.
[cols="3,5,2"]
|====
| Property | Description | Default
| 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.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.0 (slowest) to 1.0 (fastest). | 1.0
|====
== Runtime Options [[speech-options]]
The `OpenAiAudioSpeechOptions` class provides the options to use when making a text-to-speech request.
On start-up, the options specified by `spring.ai.openai.audio.speech` are used but you can override these at runtime.
For example:
[source,java]
----
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.withModel("tts-1")
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withSpeed(1.0f)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", speechOptions);
SpeechResponse response = openAiAudioSpeechClient.call(speechPrompt);
----
== Manual Configuration
Add the `spring-ai-openai` dependency to your project's Maven `pom.xml` file:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file:
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-openai'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
Next, create an `OpenAiAudioSpeechClient`:
[source,java]
----
var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY"));
var openAiAudioSpeechClient = new OpenAiAudioSpeechClient(openAiAudioApi);
var speechOptions = OpenAiAudioSpeechOptions.builder()
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withSpeed(1.0f)
.withModel(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
var speechPrompt = new SpeechPrompt("Hello, this is a text-to-speech example.", speechOptions);
SpeechResponse response = openAiAudioSpeechClient.call(speechPrompt);
// Accessing metadata (rate limit info)
OpenAiAudioSpeechResponseMetadata metadata = response.getMetadata();
byte[] responseAsBytes = response.getResult().getOutput();
----
== Streaming Real-time Audio
The Speech API provides support for real-time audio streaming using chunk transfer encoding. This means that the audio is able to be played before the full file has been generated and made accessible.
[source,java]
----
var openAiAudioApi = new OpenAiAudioApi(System.getenv("OPENAI_API_KEY"));
var openAiAudioSpeechClient = new OpenAiAudioSpeechClient(openAiAudioApi);
OpenAiAudioSpeechOptions speechOptions = OpenAiAudioSpeechOptions.builder()
.withVoice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY)
.withSpeed(1.0f)
.withResponseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.withModel(OpenAiAudioApi.TtsModel.TTS_1.value)
.build();
SpeechPrompt speechPrompt = new SpeechPrompt("Today is a wonderful day to build something people love!", speechOptions);
Flux<SpeechResponse> responseStream = openAiAudioSpeechClient.stream(speechPrompt);
----
== Example Code
* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechClientIT.java[OpenAiSpeechClientIT.java] test provides some general examples of how to use the library.

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2023 - 2024 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.openai;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* Configuration properties for OpenAI audio speech.
*
* Default values for required options are model = tts_1, response format = mp3, voice =
* alloy, and speed = 1.
*
* @author Ahmed Yousri
*/
@ConfigurationProperties(OpenAiAudioSpeechProperties.CONFIG_PREFIX)
public class OpenAiAudioSpeechProperties extends OpenAiParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.openai.audio.speech";
public static final String DEFAULT_SPEECH_MODEL = OpenAiAudioApi.TtsModel.TTS_1.getValue();
private static final Float SPEED = 1.0f;
private static final OpenAiAudioApi.SpeechRequest.Voice VOICE = OpenAiAudioApi.SpeechRequest.Voice.ALLOY;
private static final OpenAiAudioApi.SpeechRequest.AudioResponseFormat DEFAULT_RESPONSE_FORMAT = OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3;
@NestedConfigurationProperty
private OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder()
.withModel(DEFAULT_SPEECH_MODEL)
.withResponseFormat(DEFAULT_RESPONSE_FORMAT)
.withVoice(VOICE)
.withSpeed(SPEED)
.build();
public OpenAiAudioSpeechOptions getOptions() {
return options;
}
public void setOptions(OpenAiAudioSpeechOptions options) {
this.options = options;
}
}

View File

@@ -24,6 +24,7 @@ import org.springframework.ai.openai.OpenAiAudioTranscriptionClient;
import org.springframework.ai.openai.OpenAiChatClient;
import org.springframework.ai.openai.OpenAiEmbeddingClient;
import org.springframework.ai.openai.OpenAiImageClient;
import org.springframework.ai.openai.OpenAiAudioSpeechClient;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.api.OpenAiImageApi;
@@ -48,7 +49,8 @@ import org.springframework.web.client.RestClient;
@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class })
@ConditionalOnClass(OpenAiApi.class)
@EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiChatProperties.class,
OpenAiEmbeddingProperties.class, OpenAiImageProperties.class, OpenAiAudioTranscriptionProperties.class })
OpenAiEmbeddingProperties.class, OpenAiImageProperties.class, OpenAiAudioTranscriptionProperties.class,
OpenAiAudioSpeechProperties.class })
public class OpenAiAutoConfiguration {
@Bean
@@ -142,6 +144,28 @@ public class OpenAiAutoConfiguration {
return openAiChatClient;
}
@Bean
@ConditionalOnMissingBean
public OpenAiAudioSpeechClient openAiAudioSpeechClient(OpenAiConnectionProperties commonProperties,
OpenAiAudioSpeechProperties speechProperties, ResponseErrorHandler responseErrorHandler) {
String apiKey = StringUtils.hasText(speechProperties.getApiKey()) ? speechProperties.getApiKey()
: commonProperties.getApiKey();
String baseUrl = StringUtils.hasText(speechProperties.getBaseUrl()) ? speechProperties.getBaseUrl()
: commonProperties.getBaseUrl();
Assert.hasText(apiKey, "OpenAI API key must be set");
Assert.hasText(baseUrl, "OpenAI base URL must be set");
var openAiAudioApi = new OpenAiAudioApi(baseUrl, apiKey, RestClient.builder(), responseErrorHandler);
OpenAiAudioSpeechClient openAiSpeechClient = new OpenAiAudioSpeechClient(openAiAudioApi,
speechProperties.getOptions());
return openAiSpeechClient;
}
@Bean
@ConditionalOnMissingBean
public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) {

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.ai.autoconfigure.openai;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@@ -30,6 +31,7 @@ import org.springframework.ai.openai.OpenAiImageClient;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import reactor.core.publisher.Flux;
import org.springframework.ai.openai.OpenAiAudioSpeechClient;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.ai.chat.ChatResponse;
@@ -74,6 +76,32 @@ public class OpenAiAutoConfigurationIT {
});
}
@Test
void speech() {
contextRunner.run(context -> {
OpenAiAudioSpeechClient client = context.getBean(OpenAiAudioSpeechClient.class);
byte[] response = client.call("H");
assertThat(response).isNotNull();
assertThat(verifyMp3FrameHeader(response))
.withFailMessage("Expected MP3 frame header to be present in the response, but it was not found.")
.isTrue();
assertThat(response.length).isNotEqualTo(0);
logger.info("Response: " + Arrays.toString(response));
});
}
public boolean verifyMp3FrameHeader(byte[] audioResponse) {
// Check if the response is null or too short to contain a frame header
if (audioResponse == null || audioResponse.length < 2) {
return false;
}
// Check for the MP3 frame header
// 0xFFE0 is the sync word for an MP3 frame (11 bits set to 1 followed by 3 bits
// set to 0)
return (audioResponse[0] & 0xFF) == 0xFF && (audioResponse[1] & 0xE0) == 0xE0;
}
@Test
void generateStreaming() {
contextRunner.run(context -> {

View File

@@ -155,6 +155,101 @@ public class OpenAiPropertiesTests {
});
}
@Test
public void speechProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.openai.base-url=TEST_BASE_URL",
"spring.ai.openai.api-key=abc123",
"spring.ai.openai.audio.speech.options.model=TTS_1",
"spring.ai.openai.audio.speech.options.voice=alloy",
"spring.ai.openai.audio.speech.options.response-format=mp3",
"spring.ai.openai.audio.speech.options.speed=0.75")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class))
.run(context -> {
var speechProperties = context.getBean(OpenAiAudioSpeechProperties.class);
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(speechProperties.getApiKey()).isNull();
assertThat(speechProperties.getBaseUrl()).isNull();
assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_1");
assertThat(speechProperties.getOptions().getVoice())
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY);
assertThat(speechProperties.getOptions().getResponseFormat())
.isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3);
assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.75f);
});
}
@Test
public void speechPropertiesTest() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.openai.base-url=TEST_BASE_URL",
"spring.ai.openai.api-key=abc123",
"spring.ai.openai.audio.speech.options.model=TTS_1",
"spring.ai.openai.audio.speech.options.voice=alloy",
"spring.ai.openai.audio.speech.options.response-format=mp3",
"spring.ai.openai.audio.speech.options.speed=0.75")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class))
.run(context -> {
var speechProperties = context.getBean(OpenAiAudioSpeechProperties.class);
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(speechProperties.getOptions().getModel()).isEqualTo("TTS_1");
assertThat(speechProperties.getOptions().getVoice())
.isEqualTo(OpenAiAudioApi.SpeechRequest.Voice.ALLOY);
assertThat(speechProperties.getOptions().getResponseFormat())
.isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3);
assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.75f);
});
}
@Test
public void speechOverrideConnectionPropertiesTest() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.openai.base-url=TEST_BASE_URL",
"spring.ai.openai.api-key=abc123",
"spring.ai.openai.audio.speech.base-url=TEST_BASE_URL2",
"spring.ai.openai.audio.speech.api-key=456",
"spring.ai.openai.audio.speech.options.model=TTS_2",
"spring.ai.openai.audio.speech.options.voice=echo",
"spring.ai.openai.audio.speech.options.response-format=opus",
"spring.ai.openai.audio.speech.options.speed=0.5")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, OpenAiAutoConfiguration.class))
.run(context -> {
var speechProperties = context.getBean(OpenAiAudioSpeechProperties.class);
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(speechProperties.getApiKey()).isEqualTo("456");
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().getResponseFormat())
.isEqualTo(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.OPUS);
assertThat(speechProperties.getOptions().getSpeed()).isEqualTo(0.5f);
});
}
@Test
public void embeddingProperties() {