Deterministic, scripted adapter for text-to-speech testing. Implements
ALLM.SpeechAdapter and ALLM.SpeechStreamAdapter, including the
optional input-streaming callback.
Layer B — runtime. FakeSpeech is the canonical testing speech adapter; it
ships in lib/ (not test/support/) because users need it for their own
application tests, mirroring ALLM.Providers.Fake,
ALLM.Providers.FakeImages, ALLM.Providers.FakeEmbeddings and
ALLM.Providers.FakeModeration.
What FakeSpeech is (and isn't)
FakeSpeech produces no real audio. It inspects :input to enforce the
empty-input gate every ALLM.SpeechAdapter must run, reads :format to
pick the MIME type, and reads :model and :metadata to round-trip them
onto the response.
Default audio (no script)
With no script — adapter_opts[:speech_script] absent or [] — the
audio bytes are the deterministic "FAKE-AUDIO:" <> input, so two
different inputs produce two different payloads. The response :format is
request.format || :mp3, and the MIME type comes from
ALLM.SpeechResponse.format_to_mime/1.
Spent script
A non-empty script whose cursor has run off the end does NOT fall back to the default audio — it returns
{:error, %ALLM.Error.SpeechAdapterError{
reason: :unknown,
metadata: %{cause: :speech_script_exhausted}}}A spent script is almost always an off-by-one in the caller's expectation
of how many times synthesize/2 gets invoked, and a test vehicle that
answers it with default audio hides that bug.
Script shapes
opts[:adapter_opts][:speech_script] accepts a list of script entries.
See script/1 for the full grammar.
adapter_opts: [
speech_script: [
{:ok, <<"ID3...">>},
{:ok, %ALLM.SpeechResponse{...}},
{:error, %ALLM.Error.SpeechAdapterError{reason: :rate_limited}},
{:retry_until_call, 3}
]
]{:retry_until_call, n} returns a synthetic
%ALLM.Error.SpeechAdapterError{reason: :rate_limited, retry_after_ms: 0}
for the first n - 1 calls against this entry, then advances the cursor to
the next entry on call n. Consecutive {:retry_until_call, _} entries
chain into a layered budget.
| Entry | synthesize/2 | stream_synthesize/2 and stream_synthesize_input/3 |
|---|---|---|
{:ok, bytes} | bytes as the audio | :speech_started, bytes in adapter_opts[:chunk_bytes] pieces (default 1,024), :speech_completed. The input form consumes all input first |
{:ok, ""} | :malformed_response (an empty audio body) | :speech_started, then :invalid_request with metadata.cause: :empty_input |
{:ok, %SpeechResponse{}} | the struct (zero-byte audio: :malformed_response) | the struct's fields and audio as events (zero-byte audio: :speech_started, then the :empty_input error) |
{:error, err} | {:error, err} | :speech_started, then {:error, err} |
{:events, events} | :unknown with metadata.cause: :stream_only_script_entry | events, verbatim (how a mid-stream error is scripted) |
{:retry_until_call, n} | :rate_limited error | a stream whose only event is a :rate_limited error |
absent or [] script | "FAKE-AUDIO:" <> input | the same audio; the input form emits "FAKE-AUDIO:" <> chunk per non-empty chunk |
Streaming
A stream callback resolves its script entry and advances the cursor at
call time, before it returns {:ok, stream}, so two stream calls
consume two entries even if neither stream is reduced, and a halted
stream never replays its entry. Streams are never retried, so on a
stream path {:retry_until_call, n} returns a stream holding only the
rate-limit error while the entry's visit counter is below n; the
counter is shared with synthesize/2.
stream_synthesize_input/3 reduces its input through
ALLM.Providers.Support.InputPump, in another process, exactly as a real
adapter does, so an input that reads the calling process's mailbox fails
under the Fake the same way it would in production.
Cursor behaviour
Multi-call scripts advance a per-process cursor on every call. The cursor
lives in the process dictionary at {:allm_fake_speech_cursor, key_id},
isolated per ExUnit test process (async: true), GC'd on pid-down,
zero-setup for the common case. The key_id is chosen by this precedence:
adapter_opts[:script_cursor]— an explicit Agent pid (handled separately; seestart_script_cursor/0).adapter_opts[:cursor_key]— the engine's stable:id, injected by the façade dispatch chokepoint viaALLM.Engine.put_cursor_key/2.:erlang.phash2(script)— the content-hash fallback for direct adapter calls with no engine.
At the façade the cursor keys on engine identity, so two engines built with
content-equal :speech_script values each read index 0 on their first
call, even in the same process. The content-hash footgun remains only for
DIRECT adapter calls — ALLM.Providers.FakeSpeech.synthesize(req, opts)
invoked without an engine receives no :cursor_key. Workaround for that
path: pass distinct adapter_opts[:script_cursor] Agent pids from
start_script_cursor/0. The {:retry_until_call, n} visit counter keys on
the same identity, so content-equal engines never share a retry budget.
Test-only capture seam
Pass adapter_opts[:capture_pid] with a pid to receive a side-channel
message every time synthesize/2, stream_synthesize/2 or
stream_synthesize_input/3 is invoked, BEFORE any gate runs and before
the script is consulted:
{ALLM.Providers.FakeSpeech, :call, %{request: request, opts: opts}}It does NOT affect the response.
Examples
iex> req = ALLM.SpeechRequest.new(input: "Hello.", format: :wav)
iex> {:ok, resp} = ALLM.Providers.FakeSpeech.synthesize(req, [])
iex> {ALLM.Audio.to_binary(resp.audio), resp.audio.mime_type, resp.format}
{{:ok, "FAKE-AUDIO:Hello."}, "audio/wav", :wav}
Summary
Types
One scripted speech result.
Functions
Read the current cursor index for an Agent-backed cursor.
Document and validate the script grammar for adapter_opts[:speech_script].
Start an Agent-backed script cursor for cross-process multi-call scripting and for disambiguating content-equal scripts in the same process.
Stream a scripted speech request as ALLM.SpeechEvent values.
Stream a scripted speech request whose text arrives as an enumerable of
chunks. request.input is ignored.
Execute a scripted speech request.
Types
@type script_entry() :: {:ok, binary()} | {:ok, ALLM.SpeechResponse.t()} | {:error, ALLM.Error.SpeechAdapterError.t()} | {:events, [ALLM.SpeechEvent.t()]} | {:retry_until_call, pos_integer()}
One scripted speech result.
Functions
@spec cursor_index(pid()) :: non_neg_integer()
Read the current cursor index for an Agent-backed cursor.
Examples
iex> pid = ALLM.Providers.FakeSpeech.start_script_cursor()
iex> req = ALLM.SpeechRequest.new(input: "x")
iex> opts = [adapter_opts: [speech_script: [{:ok, "a"}], script_cursor: pid]]
iex> {:ok, _} = ALLM.Providers.FakeSpeech.synthesize(req, opts)
iex> ALLM.Providers.FakeSpeech.cursor_index(pid)
1
@spec script([script_entry()]) :: :ok
Document and validate the script grammar for adapter_opts[:speech_script].
Each entry is one of:
{:ok, bytes}— returnbytesas the audio, with:formatset torequest.format || :mp3and the MIME type fromALLM.SpeechResponse.format_to_mime/1.{:ok, ""}is:malformed_responsefromsynthesize/2and a stream ending with:invalid_requestandmetadata.cause: :empty_inputfrom the streaming callbacks, the error each real path gives.{:ok, %ALLM.SpeechResponse{}}— return the struct verbatim, unless its audio is zero bytes, which fails as{:ok, ""}does.{:error, %ALLM.Error.SpeechAdapterError{}}— return the struct verbatim.{:events, events}— for the streaming callbacks, emiteventsverbatim.synthesize/2answers it with:unknownandmetadata.cause: :stream_only_script_entry.{:retry_until_call, n}— synthetic:rate_limitedfor the firstn - 1calls against this entry. Consecutive entries of this shape chain into a layered budget.
Returns :ok when the script is well-formed; raises ArgumentError on the
first invalid entry. Validation is opt-in: synthesize/2 does not call it.
Examples
iex> ALLM.Providers.FakeSpeech.script([{:ok, "bytes"}, {:retry_until_call, 2}])
:ok
@spec start_script_cursor() :: pid()
Start an Agent-backed script cursor for cross-process multi-call scripting and for disambiguating content-equal scripts in the same process.
Pass the returned pid as adapter_opts[:script_cursor].
Examples
iex> pid = ALLM.Providers.FakeSpeech.start_script_cursor()
iex> ALLM.Providers.FakeSpeech.cursor_index(pid)
0
@spec stream_synthesize( ALLM.SpeechRequest.t(), keyword() ) :: {:ok, Enumerable.t(ALLM.SpeechEvent.t())} | {:error, ALLM.Error.SpeechAdapterError.t()}
Stream a scripted speech request as ALLM.SpeechEvent values.
Runs the same gates as synthesize/2, then resolves and advances the
script at call time, before the stream is returned, so two calls
consume two entries even if neither stream is reduced. The events are:
- absent script or
{:ok, bytes}—:speech_started, the bytes split intoadapter_opts[:chunk_bytes]pieces (default 1024) as:audio_deltaevents, then:speech_completed. The default bytes are"FAKE-AUDIO:" <> input.{:ok, ""}is:speech_started, then:invalid_requestwithmetadata.cause: :empty_input, the error a real stream ends with when it closes without audio (synthesize/2returns:malformed_responsefor the same entry). {:ok, %SpeechResponse{}}—:speech_startedbuilt from the struct, its audio bytes as:audio_deltaevents, then:speech_completed. Zero-byte audio ends with:invalid_requestandmetadata.cause: :empty_input, as{:ok, ""}does. Audio that cannot be read (nil, or a missing file) ends with:unknownandmetadata.cause: :unreadable_script_audio.{:error, err}—:speech_started, then{:error, err}.{:events, events}—events, verbatim.- inside a
{:retry_until_call, n}budget — a stream whose only event is a:rate_limitederror. Streams are never retried, so the next call is the next visit. - a spent non-empty script — the synchronous
:speech_script_exhaustederror.
:speech_started carries format: request.format || :mp3, its MIME type,
and sample_rate: request.sample_rate.
Examples
iex> req = ALLM.SpeechRequest.new(input: "Hi.", format: :pcm, sample_rate: 24_000)
iex> opts = [adapter_opts: [speech_script: [{:ok, "abcdef"}], chunk_bytes: 4]]
iex> {:ok, events} = ALLM.Providers.FakeSpeech.stream_synthesize(req, opts)
iex> for {:audio_delta, bytes} <- events, do: bytes
["abcd", "ef"]
@spec stream_synthesize_input( ALLM.SpeechRequest.t(), Enumerable.t(String.t()), keyword() ) :: {:ok, Enumerable.t(ALLM.SpeechEvent.t())} | {:error, ALLM.Error.SpeechAdapterError.t()}
Stream a scripted speech request whose text arrives as an enumerable of
chunks. request.input is ignored.
The request is gated synchronously with
ALLM.Validate.speech_request(request, input: :streamed): a shape error
(for example an unknown :format) returns
{:error, %SpeechAdapterError{reason: :invalid_request}} with the
validator's errors on metadata.errors, before the script is consulted
and before the input is touched.
The script entry is resolved at call time, as in stream_synthesize/2.
The input is reduced lazily, when the stream is, and through
ALLM.Providers.Support.InputPump, exactly as a real adapter reduces it,
so an input that reads the caller's mailbox fails here the same way.
- absent script — one
:audio_deltaof"FAKE-AUDIO:" <> chunkper non-empty chunk, then:speech_completed. {:ok, bytes}— consumes all the input, then emitsbytesinadapter_opts[:chunk_bytes]pieces.{:error, err},{:ok, %SpeechResponse{}},{:events, events}and{:retry_until_call, n}— as instream_synthesize/2; the input is not reduced.
Every stream that reduces the input obeys the input invariants of
ALLM.SpeechStreamAdapter: "" chunks are skipped; a non-binary or
non-UTF-8 chunk ends it with metadata.cause: :invalid_input_chunk; an
input with no non-empty chunk ends it with :empty_input; an input that
raises ends it with :input_raised and one whose pump dies from an exit
signal with :input_crashed, each with a
%{kind, message} map (an atom and a string) on the error's :cause. opts[:stream_timeout]
(default 60,000 ms) bounds the silence between input chunks and ends the
stream with :timeout. adapter_opts[:input_window] (default 8) is the
pump's credit window.
Examples
iex> req = ALLM.SpeechRequest.new(input: "")
iex> {:ok, events} = ALLM.Providers.FakeSpeech.stream_synthesize_input(req, ["a", "", "b"], [])
iex> for {:audio_delta, bytes} <- events, do: bytes
["FAKE-AUDIO:a", "FAKE-AUDIO:b"]
@spec synthesize( ALLM.SpeechRequest.t(), keyword() ) :: {:ok, ALLM.SpeechResponse.t()} | {:error, ALLM.Error.SpeechAdapterError.t()}
Execute a scripted speech request.
Gate order, all before the script is consulted:
adapter_opts[:capture_pid]side-channel (fires even for rejected calls).inputempty or not a binary →{:error, %SpeechAdapterError{reason: :invalid_request}}.
Otherwise reads the script from opts[:adapter_opts][:speech_script],
advances the process-local cursor, and interprets the entry. An absent
script produces the default audio; a spent non-empty script returns
:speech_script_exhausted. Both are described in the module docs.
Propagates opts[:request_id] onto response.request_id, request.model
onto response.model, request.sample_rate onto response.sample_rate,
request.metadata onto response.metadata, and stamps provider: :fake. {:ok, %SpeechResponse{}} script entries are
returned verbatim.
A script entry with no audio bytes, {:ok, ""} or a %SpeechResponse{}
whose audio is zero bytes, returns
{:error, %SpeechAdapterError{reason: :malformed_response}}, which is
what a real adapter's synthesize/2 returns for a successful response
with an empty body. The streaming callbacks end the same entry with
:invalid_request and metadata.cause: :empty_input instead, as a real
stream that closes without audio does; ALLM.SpeechAdapter documents
the two mappings.
Examples
iex> req = ALLM.SpeechRequest.new(input: "Hi.", metadata: %{trace: "t1"})
iex> {:ok, resp} = ALLM.Providers.FakeSpeech.synthesize(req, request_id: "rid-1")
iex> {resp.request_id, resp.metadata, resp.provider}
{"rid-1", %{trace: "t1"}, :fake}
iex> {:error, err} = ALLM.Providers.FakeSpeech.synthesize(ALLM.SpeechRequest.new(input: ""), [])
iex> err.reason
:invalid_request