ALLM.Providers.FakeSpeech (allm v0.6.1)

Copy Markdown View Source

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.

Entrysynthesize/2stream_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_entryevents, verbatim (how a mid-stream error is scripted)
{:retry_until_call, n}:rate_limited errora stream whose only event is a :rate_limited error
absent or [] script"FAKE-AUDIO:" <> inputthe 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:

  1. adapter_opts[:script_cursor] — an explicit Agent pid (handled separately; see start_script_cursor/0).
  2. adapter_opts[:cursor_key] — the engine's stable :id, injected by the façade dispatch chokepoint via ALLM.Engine.put_cursor_key/2.
  3. :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

script_entry()

@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

cursor_index(pid)

@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

script(entries)

@spec script([script_entry()]) :: :ok

Document and validate the script grammar for adapter_opts[:speech_script].

Each entry is one of:

  • {:ok, bytes} — return bytes as the audio, with :format set to request.format || :mp3 and the MIME type from ALLM.SpeechResponse.format_to_mime/1. {:ok, ""} is :malformed_response from synthesize/2 and a stream ending with :invalid_request and metadata.cause: :empty_input from 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, emit events verbatim. synthesize/2 answers it with :unknown and metadata.cause: :stream_only_script_entry.
  • {:retry_until_call, n} — synthetic :rate_limited for the first n - 1 calls 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

start_script_cursor()

@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

stream_synthesize(request, opts)

@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 into adapter_opts[:chunk_bytes] pieces (default 1024) as :audio_delta events, then :speech_completed. The default bytes are "FAKE-AUDIO:" <> input. {:ok, ""} is :speech_started, then :invalid_request with metadata.cause: :empty_input, the error a real stream ends with when it closes without audio (synthesize/2 returns :malformed_response for the same entry).
  • {:ok, %SpeechResponse{}} — :speech_started built from the struct, its audio bytes as :audio_delta events, then :speech_completed. Zero-byte audio ends with :invalid_request and metadata.cause: :empty_input, as {:ok, ""} does. Audio that cannot be read (nil, or a missing file) ends with :unknown and metadata.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_limited error. Streams are never retried, so the next call is the next visit.
  • a spent non-empty script — the synchronous :speech_script_exhausted error.

: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"]

stream_synthesize_input(request, input, opts)

@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_delta of "FAKE-AUDIO:" <> chunk per non-empty chunk, then :speech_completed.
  • {:ok, bytes} — consumes all the input, then emits bytes in adapter_opts[:chunk_bytes] pieces.
  • {:error, err}, {:ok, %SpeechResponse{}}, {:events, events} and {:retry_until_call, n} — as in stream_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"]

synthesize(request, opts)

@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:

  1. adapter_opts[:capture_pid] side-channel (fires even for rejected calls).
  2. input empty 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