LLM4S Speech Module
A comprehensive speech recognition and text-to-speech synthesis module for the LLM4S project, built with Scala and functional programming principles.
Features
Speech Recognition (STT)
- Vosk: Lightweight, offline speech recognition engine
- Whisper: High-accuracy transcription via CLI integration
- Audio Preprocessing: Resampling, channel conversion, silence trimming
- Multiple Input Formats: File, bytes, and stream audio support
- Cloud:
OpenAISTTClient(Whisper API) andAzureSTTClient(Azure AI Speech), see Cloud providers
Text-to-Speech (TTS)
- Tacotron2: Neural speech synthesis via CLI integration
- Cloud:
OpenAITTSClient,ElevenLabsTTSClientandAzureTTSClient, see Cloud providers - Voice Customization: Language, speaking rate, pitch, volume control
- Output Formats: WAV and raw PCM16 audio support
- Cross-platform: Works on Windows, Linux, and macOS
Architecture
The module follows functional programming principles with:
- Result Types:
Either[LLMError, T]for error handling - Pure Functions: Immutable audio transformations
- ADTs: Algebraic Data Types for type-safe modeling
- Composition: Functional composition for audio processing pipelines
Quick Start
Basic Usage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.llm4s.speech._
import org.llm4s.speech.stt.{WhisperSpeechToText, STTOptions}
import org.llm4s.speech.tts.{Tacotron2TextToSpeech, TTSOptions}
import org.llm4s.speech.util.PlatformCommands
// Speech Recognition
val stt = new WhisperSpeechToText(PlatformCommands.mockSuccess)
val audioInput = AudioInput.FileAudio(Paths.get("audio.wav"))
val options = STTOptions(language = Some("en"))
val result = stt.transcribe(audioInput, options)
// Text-to-Speech
val tts = new Tacotron2TextToSpeech(PlatformCommands.echo)
val ttsOptions = TTSOptions(
voice = Some("en-female"),
language = Some("en"),
speakingRate = Some(1.2)
)
val audio = tts.synthesize("Hello, world!", ttsOptions)
Audio Preprocessing
1
2
3
4
5
6
7
8
9
10
11
import org.llm4s.speech.processing.AudioPreprocessing
val audioBytes = // ... your audio data
val audioMeta = AudioMeta(sampleRate = 44100, numChannels = 2, bitDepth = 16)
// Convert to mono, resample to 16kHz for STT
val processed = AudioPreprocessing.standardizeForSTT(
audioBytes,
audioMeta,
targetRate = 16000
)
AudioPreprocessing.resamplePcm16 (the resampling step of standardizeForSTT) checks its arguments before it
converts anything. The target rate and the source rate must be between 1 and 768000 Hz, the channel count between 1
and 64, and the bit depth a multiple of 8 from 8 to 32; anything else is a Left(ValidationError) naming the field
(targetRate, source.sampleRate, source.numChannels or source.bitDepth). An output above 256 MiB (about 46
minutes of 48 kHz mono) is a ValidationError on targetRate too, checked before anything is allocated: resample a
longer recording in pieces. The output has exactly round(frames * targetRate / sourceRate) frames, so empty input
gives empty output and a trailing partial frame is ignored; a converter that delivers noticeably fewer frames than
that is a ProcessingError, not a silently padded success. Java Sound’s converter delays the signal by a fraction of a millisecond, and the length fit drops the
matching tail, so the source’s final fraction of a millisecond is not in the output.
Configuration
Vosk Configuration
1
2
3
4
5
6
7
import org.llm4s.speech.stt.VoskSpeechToText
// Use default English model
val stt = new VoskSpeechToText()
// Use custom model path
val stt = new VoskSpeechToText(modelPath = Some("/path/to/vosk-model"))
Environment Variables
1
2
# Vosk Model Path (optional)
VOSK_MODEL_PATH=/path/to/vosk-model
File Structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
src/main/scala/org/llm4s/speech/
├── Audio.scala # Core audio data structures
├── stt/ # Speech-to-Text implementations
│ ├── SpeechToText.scala # STT trait interface
│ ├── VoskSpeechToText.scala # Vosk integration
│ └── WhisperSpeechToText.scala # Whisper CLI integration
├── tts/ # Text-to-Speech implementations
│ ├── TextToSpeech.scala # TTS trait interface
│ └── Tacotron2TextToSpeech.scala # Tacotron2 CLI integration
├── processing/ # Audio preprocessing utilities
│ └── AudioPreprocessing.scala # Audio transformation functions
├── io/ # Audio I/O operations
│ └── AudioIO.scala # File saving utilities
└── util/ # Cross-platform utilities
└── PlatformCommands.scala # OS-agnostic command helpers
Cross-Platform Support
The PlatformCommands utility automatically provides the right commands:
| Platform | Echo | File Reader | Directory Listing |
|---|---|---|---|
| Windows | cmd /c echo |
cmd /c type |
cmd /c dir |
| POSIX | echo |
cat |
ls |
External Tools
Whisper
- Installation:
pip install openai-whisper - Usage: The module integrates with Whisper CLI for transcription
- Models: Supports various model sizes (tiny, base, small, medium, large)
Tacotron2
- Installation: Requires Tacotron2 CLI tool
- Usage: The module integrates with Tacotron2 CLI for synthesis
- Features: Voice customization, language support, audio output
Cloud providers
Three cloud TTS clients and two cloud STT clients live in org.llm4s.speech.tts.provider and
org.llm4s.speech.stt.provider. They are selected like chat models, with a provider/model
string, and built through SpeechProviderSelector:
| Setting | Values |
|---|---|
SPEECH_TTS_MODEL |
openai/tts-1, openai/tts-1-hd, elevenlabs/<voice-id>, azure/<voice-name> (e.g. azure/en-US-JennyNeural) |
SPEECH_TTS_VOICE |
optional voice override (OpenAI default alloy) |
SPEECH_STT_MODEL |
openai/whisper-1, azure/<default-language> (e.g. azure/en-US) |
OPENAI_API_KEY |
OpenAI TTS and STT |
ELEVENLABS_API_KEY |
ElevenLabs TTS (optional ELEVENLABS_MODEL_ID, default eleven_multilingual_v2) |
AZURE_SPEECH_KEY, AZURE_SPEECH_REGION |
Azure TTS and STT |
Only the selected provider’s credentials are needed. The same keys exist as HOCON under
llm4s.speech.* (see this module’s reference.conf); OPENAI_SPEECH_BASE_URL,
ELEVENLABS_BASE_URL, AZURE_SPEECH_TTS_BASE_URL and AZURE_SPEECH_STT_BASE_URL override the
endpoints.
1
2
3
4
5
6
7
8
9
10
11
import org.llm4s.speech.SpeechProviderSelector
import org.llm4s.speech.tts.TTSOptions
import org.llm4s.speech.stt.STTOptions
import org.llm4s.speech.AudioInput
for {
tts <- SpeechProviderSelector.tts() // reads llm4s.speech.tts / SPEECH_TTS_MODEL
audio <- tts.synthesize("Hello from LLM4S")
stt <- SpeechProviderSelector.stt() // reads llm4s.speech.stt / SPEECH_STT_MODEL
text <- stt.transcribe(AudioInput.FileAudio(java.nio.file.Paths.get("hello.wav")), STTOptions())
} yield text.text
To pick the model in code instead of SPEECH_*_MODEL, use
SpeechConfigLoader.tts("openai/tts-1") / .stt("openai/whisper-1") (credentials still come from
config) and SpeechProviderSelector.getTTSClient / getSTTClient.
Behaviour worth knowing:
- Audio is raw PCM. The TTS clients request raw 24 kHz, 16-bit, mono PCM from each service, so
GeneratedAudio.datais headerless PCM with an accurateAudioMeta, the same shape Tacotron2 produces. Write a playable file withWavFileGenerator.saveAsWav(audio, path). The defaultGeneratedAudio.formatis labelledAudioFormat.WavPcm16but, as above, the bytes carry no WAV header. - MP3 is opt-in.
TTSOptions(outputFormat = AudioFormat.Mp3)makes the OpenAI, ElevenLabs and Azure clients request the service’s MP3 (response_format=mp3,output_format=mp3_44100_128,audio-24khz-48kbitrate-mono-mp3) and return its bytes untouched: no decoding, no resampling.GeneratedAudio.formatisAudioFormat.Mp3, so the PCM helpers (WavFileGenerator.saveAsWav,AudioIO.saveWav/saveRawPcm16,AudioPreprocessing.standardizeForSTT(audio, rate)) refuse it with aValidationError; save it withAudioIO.saveMp3(audio, path).AudioMetacarries the requested sample rate and channel count as nominal labels (not verified against the service) andbitDepth = 0: MP3 has no sample width, so do not compute a duration from the byte count. Tacotron2 produces PCM only and refuses MP3. The default stays PCM. - OpenAI TTS limits are checked locally: text over 4096 characters and a
speakingRateoutside 0.25 to 4.0 are aValidationErrorbefore any request is sent. Split long text yourself; the clients do not chunk it. - STT input is WAV.
BytesAudioandStreamAudioare treated as WAV data, as for Whisper and Vosk. Azure’s REST endpoint recognises about 60 seconds of audio per request. - Errors map like the chat providers: HTTP 401/403 is
AuthenticationError, 429 isRateLimitError(withRetry-After), 400 isValidationError, other statuses areServiceError, and a timeout or refused connection isTimeoutError/NetworkError. - The clients take an
Llm4sHttpClient, so tests inject a stub and never touch the network.
Error Handling
The module uses Result[T] (alias for Either[LLMError, T]) for robust error handling:
1
2
3
4
5
6
7
8
val result: Result[Transcription] = stt.transcribe(audioInput, options)
result match {
case Right(transcription) =>
println(s"Transcript: ${transcription.text}")
case Left(error) =>
println(s"Error: ${error.formatted}")
}
Testing
The module includes comprehensive tests that work cross-platform:
1
2
3
4
5
6
7
8
# Run all tests
sbt test
# Run specific test suites
sbt "testOnly org.llm4s.speech.*"
# Run the current supported Scala build
sbt test
The cloud clients are covered without a network by speech/test (stubbed HTTP layer, including
CloudSpeechProviderIntegrationSpec). Live-API smoke suites, one per provider, are in
modules/it (org.llm4s.speech.*, tier @Cloud): run them with sbt testSmoke and
OPENAI_API_KEY, ELEVENLABS_API_KEY, AZURE_SPEECH_KEY / AZURE_SPEECH_REGION set. They make
billed calls; a suite whose key is missing is skipped, or fails under LLM4S_IT_STRICT=true.
See Also
- Examples - Working code examples
- API Reference - Complete API documentation