Audio Transcription & Speech
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). This page adds
POST /api/voice/ask to the running DocsAssistant service: a user records a question, the service turns it into text,
answers it with the same memory-backed ChatClient as the text endpoints, and sends the answer back as spoken audio.
It covers the two audio interfaces of Spring AI 2.0 (speech to text and text to speech), the providers behind them,
the behaviour of their options, streaming in both directions, and the round trip that ties them together. The
classes were compiled against the 2.0.1 jars. The OpenAI calls were made against a local HTTP server that stands in for
the provider (no account or key was used), the ElevenLabs code was compiled and its bean was created but never called,
and the endpoints were run on a Spring context with scripted models and read with curl. Nothing here was measured
against a live provider, so the latency figures are a model
of where time goes, not a benchmark.
The two interfaces
Spring AI has one interface for each direction, plus a streaming twin that each one extends.
| Purpose | Interfaces (package) | Request and response |
|---|---|---|
Speech to text |
|
|
Text to speech |
|
|
Two convenience methods cover most code: transcribe(Resource) returns the text, and call(String) on the speech model
returns the audio bytes. The streaming twins add streamTranscribe(Resource) (a Flux<String>) and stream(String)
(a Flux<byte[]>). Source:
Spring AI: Transcription API and
Spring AI: Text-To-Speech (TTS) API.
The speech interface is the one that changed name. Older material, including the books this section cites, calls it
SpeechModel and its request SpeechPrompt; the 2.0.1 jars contain no SpeechModel class (checked with javap on
2026-09-29), and code written for that name has to move to TextToSpeechModel and TextToSpeechPrompt. The two OpenAI
implementations are OpenAiAudioTranscriptionModel and OpenAiAudioSpeechModel; the book-era
OpenAiAudioTranscriptionClient is the ancestor of the first.
Which implementation becomes the bean is a property, one per direction:
-
spring.ai.model.audio.transcriptionchooses the transcription provider (openai;noneswitches it off) -
spring.ai.model.audio.speechchooses the speech provider (openaiorelevenlabs;noneswitches it off)
Starters and configuration
Audio comes with the same OpenAI starter as chat, and ElevenLabs is a separate one.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-elevenlabs</artifactId>
</dependency>
Source: Spring AI: OpenAI Transcriptions and Spring AI: OpenAI Text-To-Speech and Spring AI: ElevenLabs Text-To-Speech.
spring:
ai:
model:
audio:
transcription: openai
speech: elevenlabs # or openai
openai:
api-key: ${OPENAI_API_KEY}
audio:
transcription:
model: gpt-transcribe
speech:
model: gpt-4o-mini-tts
voice: alloy
elevenlabs:
api-key: ${ELEVENLABS_API_KEY}
tts:
model-id: eleven_multilingual_v2
voice-id: ${ELEVENLABS_VOICE_ID}
docsassistant:
voice:
elevenlabs-voice-id: ${ELEVENLABS_VOICE_ID}
Source: Spring AI: OpenAI Transcriptions and Spring AI: OpenAI Text-To-Speech and Spring AI: ElevenLabs Text-To-Speech.
The property names were checked against the starters' configuration metadata. Set spring.ai.model.audio.speech
explicitly whenever more than one speech starter is on the classpath. With both the OpenAI and the ElevenLabs starters
present and no selector, a scratch context created two TextToSpeechModel beans (openAiSdkAudioSpeechModel and
elevenLabsSpeechModel) and any single-bean injection would fail; with spring.ai.model.audio.speech=elevenlabs only
elevenLabsSpeechModel remained, and with openai only openAiSdkAudioSpeechModel. The same holds for images, see
Image Generation. The setup basics are on
Setup & Starters.
Speech to text
The Spring AI reference lists gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize (speaker
labels) and whisper-1, which is what you get when nothing is configured. OpenAI’s own speech-to-text guide, checked on
2026-09-29, now recommends gpt-transcribe for recorded speech. The model option is a plain string, so any name the API
accepts can be configured; check the guide for the current recommendation before you pick one. Sources:
Spring AI: OpenAI Transcriptions
and OpenAI: Speech to text.
package com.example.docsassistant;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionOptions;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
class TranscriptionService {
private final TranscriptionModel transcriptionModel;
private final OpenAiAudioTranscriptionOptions vocabulary;
TranscriptionService(TranscriptionModel transcriptionModel) {
this.transcriptionModel = transcriptionModel;
// The builder of the per-request options fills in the class default model (whisper-1), which then wins
// over the model you configured. Copying the model's own options first keeps the configured model.
if (transcriptionModel instanceof OpenAiAudioTranscriptionModel openAi) {
this.vocabulary = OpenAiAudioTranscriptionOptions.builder()
.from(openAi.getOptions())
.language("en")
.prompt("Antora, AsciiDoc, Asciidoctor, Spring AI, pgvector, MCP")
.build();
}
else {
this.vocabulary = null;
}
}
/** One call, the whole recording, the configured defaults. */
String transcribe(Resource audio) {
return this.transcriptionModel.transcribe(audio);
}
/** Language hint and a vocabulary prompt: fewer mistakes on the site's own product names. */
String transcribeWithVocabulary(Resource audio) {
if (this.vocabulary == null) {
return this.transcriptionModel.transcribe(audio);
}
return this.transcriptionModel.transcribe(audio, this.vocabulary);
}
/** The text as the provider produces it. Needs a model that supports streaming; whisper-1 does not. */
Flux<String> transcribeAsItArrives(Resource audio) {
return this.transcriptionModel.streamTranscribe(audio)
.filter(piece -> !piece.isEmpty());
}
}
Source: Spring AI: Transcription API.
Four details of the options are worth knowing before you rely on them.
-
The request options are merged with the configured ones, but the builder pre-fills the model. In a check against a local stand-in for OpenAI, a model configured as
gpt-4o-mini-transcribe(the model the Spring AI reference documents for streaming) and a request that set onlylanguageandpromptwent out withwhisper-1: the language and prompt were merged with the configured values, the model was replaced by the class default.TranscriptionServicetherefore copies the model’s own options withfrom(…)before adding to them. Always set the model on provider-specific request options, or copy the configured ones. -
A language hint helps.
languagetakes an ISO-639-1 code; the reference says it improves accuracy and latency. -
A prompt teaches vocabulary. The
promptoption guides style and spelling. Listing the product names of the site (Antora, AsciiDoc, pgvector) is cheap and helps with words that a general model would spell as ordinary ones. -
Response formats.
textis the default.verbose_jsonanddiarized_jsonadd duration, language, segments and words to the response metadata (OpenAiAudioTranscriptionResponseMetadata), andtimestamp-granularitiesneedsverbose_json.diarized_jsonneedsgpt-4o-transcribe-diarize.
The resource needs a file name. audio.getResource() on a Spring MultipartFile keeps the uploaded name: the request
that the stand-in server received carried filename="q.wav". A ByteArrayResource without a name was sent as
filename="audio", with no extension. Give in-memory audio a name with the right extension (override getFilename()), so
the provider can tell which format it received. OpenAI’s guide lists mp3, mp4, mpeg, mpga, m4a, wav and webm and a limit of
25 MB per file; enforce your own smaller limit, as the controller below does.
Streaming transcription
streamTranscribe and stream(AudioTranscriptionPrompt) return the text in pieces. In 2.0 TranscriptionModel
extends StreamingTranscriptionModel, and the default stream() of the interface fails with an
UnsupportedOperationException inside the Flux; OpenAiAudioTranscriptionModel overrides it. Against a stand-in that
answered in OpenAI’s server-sent-event format, the request carried stream=true and the flux delivered
"How do I ", "use Antora?" and a final empty string, which is why transcribeAsItArrives filters empty pieces.
OpenAI’s guide states that gpt-transcribe and the GPT-4o transcription models support file streaming and that
whisper-1 does not, so do not rely on streaming with whisper-1.
What this streams is the result for a recording that already exists in full. It shortens the wait for the text of a long upload, but it does not listen to a microphone: the audio is sent as one file first. Live capture, voice-activity detection and interrupting the speaker are the subject of the Voice Agents sub-section (see Turn-Taking and Interruptions), which covers real-time and telephony pipelines.
Text to speech
package com.example.docsassistant;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechOptions;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.audio.tts.TextToSpeechResponse;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
class SpeechService {
private final TextToSpeechModel speechModel;
SpeechService(TextToSpeechModel speechModel) {
this.speechModel = speechModel;
}
/** The whole answer as one MP3 (OpenAI's defaults: gpt-4o-mini-tts, alloy, mp3). */
byte[] speak(String text) {
return this.speechModel.call(text);
}
/** Portable options: model, voice, format and speed exist on every provider; the values are the provider's. */
byte[] speakPortable(String text) {
TextToSpeechOptions options = TextToSpeechOptions.builder()
.voice("nova")
.format("opus")
.speed(1.1)
.build();
TextToSpeechResponse response = this.speechModel.call(new TextToSpeechPrompt(text, options));
return response.getResult().getOutput();
}
/** OpenAI-only option: instructions steer tone and pace on gpt-4o-mini-tts (ignored by tts-1). */
byte[] speakCalmly(String text) {
OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder()
.model("gpt-4o-mini-tts")
.voice(OpenAiAudioSpeechOptions.Voice.CORAL)
.responseFormat(OpenAiAudioSpeechOptions.AudioResponseFormat.MP3)
.instructions("Calm, friendly, clear pauses between sentences.")
.build();
return this.speechModel.call(new TextToSpeechPrompt(text, options)).getResult().getOutput();
}
/** Audio chunks as the provider produces them (at most 8 KB each on OpenAI). */
Flux<byte[]> speakAsItArrives(String text) {
return this.speechModel.stream(text);
}
}
Source: Spring AI: Text-To-Speech (TTS) API.
The OpenAI speech options, per the reference:
| Option | Values | Default |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
0.25 to 4.0 |
1.0 |
|
Free text on tone and pace; only models such as |
none |
Source: Spring AI: OpenAI Text-To-Speech.
Portable TextToSpeechOptions (model, voice, format, speed) work with every provider, and in the stand-in check
they were merged with the configured model: a model configured as tts-1-hd and a request that set voice, format and
speed sent tts-1-hd with the request’s values, and a configured instructions value survived. A provider-specific
OpenAiAudioSpeechOptions built without a model went out with gpt-4o-mini-tts instead, the same class-default effect
as for transcription. State the model whenever you build provider options.
ElevenLabs
ElevenLabs is the second speech provider in the reference. Its starter is spring-ai-starter-model-elevenlabs, the model
class is ElevenLabsTextToSpeechModel, and its options carry the voice by ID, not by name. The defaults are
eleven_turbo_v2_5 for the model, the voice ID 9BWtsMINqrJLrRacOk9x and mp3_22050_32 as the output format.
Credentials are spring.ai.elevenlabs.api-key. Source:
Spring AI: ElevenLabs Text-To-Speech.
package com.example.docsassistant;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.elevenlabs.ElevenLabsTextToSpeechOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
class ElevenLabsSpeech {
private final TextToSpeechModel speechModel;
private final String voiceId;
// needs spring-ai-starter-model-elevenlabs and spring.ai.model.audio.speech=elevenlabs
ElevenLabsSpeech(TextToSpeechModel speechModel, @Value("${docsassistant.voice.elevenlabs-voice-id}") String voiceId) {
this.speechModel = speechModel;
this.voiceId = voiceId;
}
byte[] speak(String text) {
ElevenLabsTextToSpeechOptions options = ElevenLabsTextToSpeechOptions.builder()
.model("eleven_multilingual_v2")
.voiceId(this.voiceId)
.outputFormat("mp3_44100_128")
.languageCode("en")
.build();
return this.speechModel.call(new TextToSpeechPrompt(text, options)).getResult().getOutput();
}
}
Source: Spring AI: ElevenLabs Text-To-Speech.
Beyond the portable four, ElevenLabsTextToSpeechOptions has voiceSettings (stability, similarity boost, style,
speaker boost and speed), languageCode, seed for repeatable output, pronunciation dictionaries,
previousText and nextText for continuity across separately generated pieces, and text-normalization switches. The
ElevenLabsVoicesApi lists the voices your account can use, which is where a voice ID comes from. A cloned voice is a
person’s voice: use only voices you have the right to use, and say in your interface that the audio is synthetic.
Streaming speech
stream(String) returns Flux<byte[]> and stream(TextToSpeechPrompt) returns Flux<TextToSpeechResponse>. On OpenAI
the request went out with stream_format set to audio, and in the stand-in check a 20,000-byte answer arrived as three
chunks. The reference says a chunk holds at most 8 KB and that each open stream occupies a thread, so the OpenAI model
runs streams on its own bounded-elastic scheduler, replaceable with streamScheduler(…) on the model builder. It
also documents a bridge from Flux<byte[]> to an InputStream for libraries that need one, and warns that
toIterable() cannot cancel the subscription. ElevenLabs supports streaming too. Unlike transcription, there is no
throwing default here: stream(TextToSpeechPrompt) is abstract, so every TextToSpeechModel must implement it. The
UnsupportedOperationException default exists only on the transcription side. Still test a provider’s stream before
you promise streaming to users.
The voice round trip
Audio in, text through the ChatClient the rest of the service uses, audio out.
The listener waits for the sum of three model calls. If is the transcription time, the time for the answer text and the time to synthesise it, then
and a streamed reply lets the listener hear audio after only , where is the time to the first audio chunk. The dominant term is usually , and the answer’s length drives both and , which is why the pipeline asks the model for at most four short sentences. These are terms of a model, not measurements; time each stage in your own environment.
package com.example.docsassistant;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.core.io.Resource;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
/** Audio in, text through the ChatClient the rest of DocsAssistant uses, audio out. */
@Service
class VoiceAssistantService {
private static final int MAX_SPOKEN_CHARS = 1500;
private final TranscriptionModel transcription;
private final TextToSpeechModel speech;
private final ChatClient chatClient;
private final ConversationIds conversationIds;
VoiceAssistantService(TranscriptionModel transcription, TextToSpeechModel speech,
ChatClient memoryChatClient, ConversationIds conversationIds) {
this.transcription = transcription;
this.speech = speech;
this.chatClient = memoryChatClient;
this.conversationIds = conversationIds;
}
record SpokenAnswer(String question, String answer, byte[] audio) {
}
SpokenAnswer ask(Authentication user, String conversation, Resource recording) {
String question = this.transcription.transcribe(recording);
String answer = answer(user, conversation, question);
return new SpokenAnswer(question, answer, this.speech.call(SpokenText.forSpeech(answer, MAX_SPOKEN_CHARS)));
}
/** Same pipeline, but the audio is handed on chunk by chunk while the provider is still producing it. */
Flux<byte[]> askStreaming(Authentication user, String conversation, Resource recording) {
String question = this.transcription.transcribe(recording);
String answer = answer(user, conversation, question);
return this.speech.stream(SpokenText.forSpeech(answer, MAX_SPOKEN_CHARS));
}
private String answer(Authentication user, String conversation, String question) {
if (question == null || question.isBlank()) {
throw new IllegalArgumentException("no speech found in the recording");
}
return this.chatClient.prompt()
.system("The answer will be read aloud: at most four short sentences, no lists, no code, "
+ "no markdown. Say where a code example can be found instead of reading it.")
.user(question)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID,
this.conversationIds.forUser(user, conversation)))
.call()
.content();
}
}
The conversation ID comes from the authenticated user and a validated label, through the ConversationIds component from
Chat Memory, so a listener can never read or write another user’s conversation by
choosing a label. The system prompt for speech asks for short sentences, no lists, no code and no markdown; a written
answer is a poor script. SpokenText is the safety net for when the model writes markup anyway.
package com.example.docsassistant;
import java.util.regex.Pattern;
/** Turns a written answer into something worth reading aloud. */
final class SpokenText {
private static final Pattern CODE_BLOCK = Pattern.compile("(?s)```.*?```");
private static final Pattern LINK = Pattern.compile("\\[([^\\]]+)]\\([^)]*\\)");
private static final Pattern MARKUP = Pattern.compile("[`*_#>|]");
private SpokenText() {
}
static String forSpeech(String answer, int maxChars) {
String text = CODE_BLOCK.matcher(answer).replaceAll(" (a code example is in the written answer) ");
text = LINK.matcher(text).replaceAll("$1");
text = MARKUP.matcher(text).replaceAll("");
text = text.replaceAll("\\s+", " ").strip();
if (text.length() <= maxChars) {
return text;
}
int cut = text.lastIndexOf(". ", maxChars);
return text.substring(0, cut > 0 ? cut + 1 : maxChars);
}
}
Source: Spring AI: Text-To-Speech (TTS) API.
package com.example.docsassistant;
import java.io.IOException;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import reactor.core.Disposable;
@RestController
class VoiceController {
private static final long MAX_BYTES = 10L * 1024 * 1024;
private static final MediaType MPEG = MediaType.parseMediaType("audio/mpeg");
private final VoiceAssistantService voice;
VoiceController(VoiceAssistantService voice) {
this.voice = voice;
}
/** Multipart audio in, one MP3 out. */
@PostMapping(path = "/api/voice/ask", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = "audio/mpeg")
byte[] ask(@RequestParam("audio") MultipartFile audio,
@RequestParam(name = "session", defaultValue = "voice") String session, Authentication user) {
return this.voice.ask(user, session, checked(audio)).audio();
}
/** Same request, but the MP3 leaves as the provider produces it, and a listener who hangs up stops the work. */
@PostMapping(path = "/api/voice/ask/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = "audio/mpeg")
ResponseEntity<ResponseBodyEmitter> askStreaming(@RequestParam("audio") MultipartFile audio,
@RequestParam(name = "session", defaultValue = "voice") String session, Authentication user) {
ResponseBodyEmitter emitter = new ResponseBodyEmitter(120_000L);
Disposable subscription = this.voice.askStreaming(user, session, checked(audio))
.subscribe(
chunk -> send(emitter, chunk),
emitter::completeWithError,
emitter::complete);
emitter.onCompletion(subscription::dispose);
emitter.onError(error -> subscription.dispose());
emitter.onTimeout(subscription::dispose);
return ResponseEntity.ok().contentType(MPEG).body(emitter);
}
private static void send(ResponseBodyEmitter emitter, byte[] chunk) {
try {
emitter.send(chunk, MPEG);
}
catch (IOException | IllegalStateException ex) {
emitter.completeWithError(ex); // the listener left; onError cancels the provider stream
}
}
private static Resource checked(MultipartFile audio) {
if (audio.isEmpty() || audio.getSize() > MAX_BYTES) {
throw new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "audio must be 1 byte to 10 MiB");
}
return audio.getResource();
}
}
The streaming variant is the result of two attempts that did not work, both run on a servlet application with the scripted models:
| Approach | Observed |
|---|---|
Return |
HTTP 500: a |
Return a |
Works, but after the client had disconnected (a reset connection) all 40 chunk writes still succeeded and the provider stream ran to completion |
Return a |
Chunked |
So the endpoint uses a ResponseBodyEmitter and cancels the subscription in its completion, error and timeout
callbacks. The blocking work that precedes the audio (transcription and the chat call) runs on the request thread before
the emitter is returned; only the speech synthesis is streamed. The response needs an explicit Content-Type, which is
why the method returns a ResponseEntity; without it the scratch response had none.
In the scratch run, the ordinary endpoint answered 200 with audio/mpeg, the request carried the multipart file name
through to the transcription model, and the text sent to the speech model was Use nav.adoc. See the guide. (a code
example is in the written answer) Then rebuild. for a model answer that contained bold text, a link and a code fence. A
request without credentials was answered with 401.
Limits, privacy and cost
-
Bound everything a caller controls. The controller rejects recordings over 10 MiB,
SpokenTextcaps the spoken answer, and the model is told to be brief. Addspring.servlet.multipart.max-file-sizefor the container, and rate-limit the endpoint: each request costs a transcription, a chat completion and a synthesis. Check the provider’s own limits on file size and on the text length of a speech request. -
A voice is personal data. A recording identifies a speaker, and a synthetic voice can be misused. Tell users that audio is sent to a third party, do not keep recordings longer than you need them, and keep prompt and completion logging off in production or restricted, as explained on Observability.
-
Say that the voice is synthetic. A spoken answer should not pass as a person.
-
Treat the transcript as untrusted input. It is the user’s words, or whatever the recording contained, so it goes into the same guardrails as typed text (see Security & Guardrails).
-
Accents and noise cost accuracy. Set
language, use the vocabulary prompt, and keep a small set of real recordings to compare models on.
Testing
The two model interfaces are small, so fakes are easy: TranscriptionModel has one abstract method, which makes a lambda
enough, and TextToSpeechModel has two (call and stream), which needs a small class. The test below drives the whole
pipeline with a fake transcriber, a fake speaker and a canned chat model, and checks what reaches the speech model.
package com.example.docsassistant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.audio.transcription.AudioTranscription;
import org.springframework.ai.audio.transcription.AudioTranscriptionResponse;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.audio.tts.Speech;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.audio.tts.TextToSpeechResponse;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.security.authentication.TestingAuthenticationToken;
import reactor.core.publisher.Flux;
class VoiceAssistantServiceTest {
@Test
void speaksTheAnswerToTheQuestionItHeard() {
List<String> spoken = new ArrayList<>();
TranscriptionModel stt = prompt ->
new AudioTranscriptionResponse(new AudioTranscription("How do I add a page to the nav?"));
TextToSpeechModel tts = new TextToSpeechModel() {
@Override
public TextToSpeechResponse call(TextToSpeechPrompt prompt) {
spoken.add(prompt.getInstructions().getText());
return new TextToSpeechResponse(List.of(new Speech(new byte[] {1, 2, 3})));
}
@Override
public Flux<TextToSpeechResponse> stream(TextToSpeechPrompt prompt) {
return Flux.just(call(prompt));
}
};
ChatModel chat = prompt -> new ChatResponse(List.of(new Generation(
new AssistantMessage("Add it to **nav.adoc**. See [the guide](https://example.org/nav)."))));
ChatMemory memory = MessageWindowChatMemory.builder().maxMessages(10).build();
ChatClient client = ChatClient.builder(chat)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
.build();
var service = new VoiceAssistantService(stt, tts, client, new ConversationIds());
var user = new TestingAuthenticationToken("ana", "n/a", "ROLE_USER");
var result = service.ask(user, "voice", new ByteArrayResource(new byte[] {1}));
assertThat(result.question()).isEqualTo("How do I add a page to the nav?");
assertThat(result.audio()).containsExactly(1, 2, 3);
assertThat(spoken).containsExactly("Add it to nav.adoc. See the guide.");
}
@Test
void shortensLongAnswersAtASentenceBoundary() {
String text = "First sentence. Second sentence. " + "x".repeat(100);
assertThat(SpokenText.forSpeech(text, 40)).isEqualTo("First sentence. Second sentence.");
}
}
Against the real providers, keep a handful of short recordings and assert on the transcript loosely (a normalised comparison, not equality), and treat those tests as slow, paid integration tests behind a profile. More patterns are on Testing, and the model-level checks on Evaluation.
Real time and telephony
Everything on this page is turn-based: one recording in, one reply out. Conversations where the assistant listens while it talks, can be interrupted, or answers a phone call need a live audio transport and a realtime model, which is the scope of the Voice Agents sub-section of this AI reference (Realtime APIs for speech-to-speech). The building blocks here (a transcript you can log, a chat model with memory, a synthesiser) are still the parts a turn-based fallback uses.
Where to go next
-
Multimodality sends audio to a chat model that accepts it directly.
-
Streaming & Reactive explains the text streaming this page’s audio streaming is modelled on.