# `ALLM.Providers.FakeSpeech`
[🔗](https://github.com/cykod/ALLM/blob/v0.6.1/lib/allm/providers/fake_speech.ex#L1)

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:

  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}

# `script_entry`

```elixir
@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.

# `cursor_index`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
