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.

Table 1. Audio interfaces in Spring AI 2.0.1
Purpose Interfaces (package) Request and response

Speech to text

TranscriptionModel extends StreamingTranscriptionModel (org.springframework.ai.audio.transcription)

AudioTranscriptionPrompt (a Resource plus options) in, AudioTranscriptionResponse (text plus metadata) out

Text to speech

TextToSpeechModel extends StreamingTextToSpeechModel (org.springframework.ai.audio.tts)

TextToSpeechPrompt (text plus options) in, TextToSpeechResponse (audio bytes) out

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.transcription chooses the transcription provider (openai; none switches it off)

  • spring.ai.model.audio.speech chooses the speech provider (openai or elevenlabs; none switches it off)

Starters and configuration

Audio comes with the same OpenAI starter as chat, and ElevenLabs is a separate one.

pom.xml: dependencies (versions come from the Spring AI BOM)
<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>
application.yml: keys come from the environment, never from the file
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}

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.

TranscriptionService.java: one call, options, and streaming
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());
    }
}

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 only language and prompt went out with whisper-1: the language and prompt were merged with the configured values, the model was replaced by the class default. TranscriptionService therefore copies the model’s own options with from(…​) before adding to them. Always set the model on provider-specific request options, or copy the configured ones.

  • A language hint helps. language takes an ISO-639-1 code; the reference says it improves accuracy and latency.

  • A prompt teaches vocabulary. The prompt option 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. text is the default. verbose_json and diarized_json add duration, language, segments and words to the response metadata (OpenAiAudioTranscriptionResponseMetadata), and timestamp-granularities needs verbose_json. diarized_json needs gpt-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

SpeechService.java: whole answer, portable options, OpenAI options and streaming
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);
    }
}

The OpenAI speech options, per the reference:

Table 2. OpenAI speech options (Spring AI 2.0.1)
Option Values Default

model

gpt-4o-mini-tts, gpt-4o-tts, tts-1, tts-1-hd

gpt-4o-mini-tts

voice

alloy, echo, fable, onyx, nova, shimmer; the Voice constants in 2.0.1 add ballad, sage, coral, verse and ash

alloy

responseFormat

mp3, opus, aac, flac, wav, pcm

mp3

speed

0.25 to 4.0

1.0

instructions

Free text on tone and pace; only models such as gpt-4o-mini-tts honour it, tts-1 and tts-1-hd ignore it

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.

ElevenLabsSpeech.java: provider-specific options
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();
    }
}

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.

sequenceDiagram participant U as Listener participant C as VoiceController participant V as VoiceAssistantService participant T as TranscriptionModel participant A as ChatClient with memory participant S as TextToSpeechModel U->>C: POST /api/voice/ask (multipart audio) C->>C: size check, authenticated user C->>V: recording, session label, user V->>T: transcribe(recording) T-->>V: question text V->>A: question, conversation id from the user A-->>V: answer text (short, no markup) V->>V: SpokenText.forSpeech V->>S: stream or call(text) S-->>C: audio chunks or one MP3 C-->>U: audio/mpeg
Figure 1. One spoken question: where the time goes

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.

VoiceAssistantService.java: transcribe, answer with memory, synthesise
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.

SpokenText.java: from a written answer to something worth reading aloud
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);
    }
}
VoiceController.java: POST /api/voice/ask and a streaming variant
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:

Table 3. Ways to stream binary audio from Spring MVC, as observed on Spring Boot 4.1.1
Approach Observed

Return Flux<byte[]> with produces = "audio/mpeg"

HTTP 500: a Flux of byte arrays is not a streaming type in Spring MVC, which supports server-sent events and JSON streams

Return a StreamingResponseBody that writes each chunk and flushes

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 ResponseBodyEmitter fed by the Flux

Chunked audio/mpeg, 80 bytes for the 40 two-byte test chunks; when the client left after one second the emitter reported the failure and the provider stream was cancelled

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, SpokenText caps the spoken answer, and the model is told to be brief. Add spring.servlet.multipart.max-file-size for 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.

VoiceAssistantServiceTest.java
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