Added support for OpenAI Text to Audio (Speech API )
* Added documentation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 + '}';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 + '}';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user