Deterministic, scripted adapter for speech-to-text testing. Implements
ALLM.TranscriptionAdapter and ALLM.TranscriptionStreamAdapter.
Layer B — runtime. FakeTranscription is the canonical testing
transcription adapter; it ships in lib/ (not test/support/) because
users need it for their own application tests, mirroring
ALLM.Providers.FakeSpeech and ALLM.Providers.FakeModeration.
What FakeTranscription is (and isn't)
FakeTranscription never listens to the audio. It measures :audio to run
the resolvable and size gates every ALLM.TranscriptionAdapter must run,
and reads :model and :metadata to round-trip them onto the response.
It has no MIME gate: accepted MIME types are a real provider's concern.
Size cap
max_audio_bytes/0 returns 1024, deliberately small so a test crosses
the boundary cheaply. The gate measures against
adapter_opts[:max_audio_bytes] || max_audio_bytes(). A real adapter that
hands a scripted call off to this Fake passes its own cap under that key,
so a scripted run with a real multi-kilobyte clip is not rejected by the
Fake's small default.
Default transcript (no script)
With no script — adapter_opts[:transcription_script] absent or []
— the response carries text: "" and usage: %ALLM.Usage{}.
Spent script
A non-empty script whose cursor has run off the end does NOT fall back to the default — it returns
{:error, %ALLM.Error.TranscriptionAdapterError{
reason: :unknown,
metadata: %{cause: :transcription_script_exhausted}}}Script shapes
opts[:adapter_opts][:transcription_script] accepts a list of script
entries. See script/1 for the full grammar.
adapter_opts: [
transcription_script: [
{:ok, "The quick brown fox."},
{:ok, %ALLM.TranscriptionResponse{...}},
{:error, %ALLM.Error.TranscriptionAdapterError{reason: :rate_limited}},
{:retry_until_call, 3}
]
]{:retry_until_call, n} returns a synthetic
%ALLM.Error.TranscriptionAdapterError{reason: :rate_limited, retry_after_ms: 0}
for the first n - 1 calls against this entry, then advances to the next
entry on call n. Consecutive entries of this shape chain. n must be a
positive integer: any other budget raises ArgumentError naming the entry
when a call reaches it, whether or not the script went through script/1.
| Entry | transcribe/2 | stream_transcribe/3 |
|---|---|---|
{:ok, text} | text as the transcript; with a span flag set, one :word span per word | after the input is consumed: one partial per cumulative word prefix, one committed segment, :transcription_completed; with a span flag set, both carry the word spans |
{:ok, %TranscriptionResponse{}} | the struct, verbatim (span flags not applied) | the struct's fields as events; a non-nil :spans rides the committed and completed events |
{:error, err} | {:error, err} | :transcription_started, then {:error, err} |
{:events, events} | :unknown with metadata.cause: :stream_only_script_entry | events, verbatim |
{:retry_until_call, n} | :rate_limited error | a stream whose only event is a :rate_limited error |
absent or [] script | text: "" (spans: [] with a span flag set) | :transcription_completed with text: "" (and spans: [] with a span flag set) |
Span flags
With timestamps: true or logprobs: true on the request, a text entry
(and the no-script default) is split on whitespace into one :word
ALLM.TranscriptSpan per word: word i spans i * 0.5 to
(i + 1) * 0.5 seconds with logprob -0.1. Times are kept only under
timestamps: true and the logprob only under logprobs: true; a flag
counts only when it is exactly true. Blank text gives spans: []; with
both flags off spans is nil and no stream event carries a :spans
key.
iex> req = ALLM.TranscriptionRequest.new(
...> audio: ALLM.Audio.from_binary(<<0>>, "audio/mpeg"), logprobs: true)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi there"}]]]
iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> for s <- resp.spans, do: {s.text, s.start_seconds, s.logprob}
[{"hi", nil, -0.1}, {"there", nil, -0.1}]On the stream path the one committed segment carries the same spans and
:transcription_completed carries them under :spans ([] when nothing
was committed). A scripted %TranscriptionResponse{} is the escape
hatch: its own :spans is returned (or streamed) as scripted, whatever
the flags say.
adapter_opts[:span_flags], when present, is the list of flags the
caller's adapter supports. A set flag outside it is refused with
{:error, %ALLM.Error.TranscriptionAdapterError{reason: :unsupported_feature, metadata: %{field: flag}}} after the audio (or sample-rate) gate and
before the script is consulted, so the refusal does not advance the
cursor; :timestamps is checked before :logprobs. The error's
:provider is nil. Without the key both flags are supported.
iex> req = ALLM.TranscriptionRequest.new(
...> audio: ALLM.Audio.from_binary(<<0>>, "audio/mpeg"), timestamps: true)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi"}], span_flags: [:logprobs]]]
iex> {:error, err} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> {err.reason, err.metadata.field}
{:unsupported_feature, :timestamps}Streaming
stream_transcribe/3 resolves its script entry and advances the cursor
at call time, before it returns {:ok, stream}, so two calls consume
two entries even if neither stream is reduced. Streams are never retried,
so on the stream path {:retry_until_call, n} returns a stream holding
only the rate-limit error while the entry's visit counter (shared with
transcribe/2) is below n. The input is reduced through
ALLM.Providers.Support.InputPump, in another process, as a real adapter
reduces it. stream_sample_rates/0 is [8_000, 16_000, 24_000].
Cursor behaviour
Multi-call scripts advance a per-process cursor on every call. The cursor
lives in the process dictionary at
{:allm_fake_transcription_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 :transcription_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.FakeTranscription.transcribe(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 transcribe/2 or stream_transcribe/3 is invoked,
BEFORE any gate runs and before the script is consulted:
{ALLM.Providers.FakeTranscription, :call, %{request: request, opts: opts}}Examples
iex> audio = ALLM.Audio.from_binary(<<0, 1, 2>>, "audio/mpeg")
iex> req = ALLM.TranscriptionRequest.new(audio: audio)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "Hello."}]]]
iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> resp.text
"Hello."
Summary
Types
One scripted transcription result.
Functions
Read the current cursor index for an Agent-backed cursor.
Return the largest audio payload FakeTranscription accepts, in bytes.
Document and validate the script grammar for
adapter_opts[:transcription_script].
Start an Agent-backed script cursor for cross-process multi-call scripting and for disambiguating content-equal scripts in the same process.
Return the PCM sample rates stream_transcribe/3 accepts:
[8000, 16000, 24000]. Override per call with
adapter_opts[:stream_sample_rates].
Stream a scripted realtime transcription as ALLM.TranscriptionEvent
values.
Execute a scripted transcription request.
Types
@type script_entry() :: {:ok, String.t()} | {:ok, ALLM.TranscriptionResponse.t()} | {:error, ALLM.Error.TranscriptionAdapterError.t()} | {:events, [ALLM.TranscriptionEvent.t()]} | {:retry_until_call, pos_integer()}
One scripted transcription result.
Functions
@spec cursor_index(pid()) :: non_neg_integer()
Read the current cursor index for an Agent-backed cursor.
Examples
iex> pid = ALLM.Providers.FakeTranscription.start_script_cursor()
iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_binary("x", "audio/mpeg"))
iex> opts = [adapter_opts: [transcription_script: [{:ok, "a"}], script_cursor: pid]]
iex> {:ok, _} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> ALLM.Providers.FakeTranscription.cursor_index(pid)
1
@spec max_audio_bytes() :: pos_integer()
Return the largest audio payload FakeTranscription accepts, in bytes.
Deliberately not a provider-shaped number: 1024 bytes lets a test cross
the size boundary without allocating megabytes. Override per call with
adapter_opts[:max_audio_bytes].
Examples
iex> ALLM.Providers.FakeTranscription.max_audio_bytes()
1024
@spec script([script_entry()]) :: :ok
Document and validate the script grammar for
adapter_opts[:transcription_script].
Each entry is one of:
{:ok, text}— returntextas the transcript, withusage: %ALLM.Usage{}.{:ok, %ALLM.TranscriptionResponse{}}— return the struct verbatim.{:error, %ALLM.Error.TranscriptionAdapterError{}}— return the struct verbatim.{:events, events}— forstream_transcribe/3, emiteventsverbatim.transcribe/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: transcribe/2 does not call it,
though a malformed {:retry_until_call, n} still raises when a call
reaches it.
Examples
iex> ALLM.Providers.FakeTranscription.script([{:ok, "text"}, {: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.FakeTranscription.start_script_cursor()
iex> ALLM.Providers.FakeTranscription.cursor_index(pid)
0
@spec stream_sample_rates() :: [pos_integer()]
Return the PCM sample rates stream_transcribe/3 accepts:
[8000, 16000, 24000]. Override per call with
adapter_opts[:stream_sample_rates].
Examples
iex> ALLM.Providers.FakeTranscription.stream_sample_rates()
[8000, 16000, 24000]
@spec stream_transcribe( ALLM.TranscriptionStreamRequest.t(), Enumerable.t(binary() | :commit), keyword() ) :: {:ok, Enumerable.t(ALLM.TranscriptionEvent.t())} | {:error, ALLM.Error.TranscriptionAdapterError.t()}
Stream a scripted realtime transcription as ALLM.TranscriptionEvent
values.
Gate, before the script is consulted: request.sample_rate must be in
adapter_opts[:stream_sample_rates] || stream_sample_rates(), else a
synchronous {:error, %TranscriptionAdapterError{reason: :invalid_request}}
with metadata.sample_rate. Then, when adapter_opts[:span_flags] is
present, the span-flag gate transcribe/2 runs (:unsupported_feature,
also synchronous).
The script entry is resolved and the cursor advanced at call time,
before the stream is returned. The input is reduced lazily, when the
stream is, through ALLM.Providers.Support.InputPump, and every element
is checked as a real adapter checks it: a binary or :commit, else
metadata.cause: :invalid_input_chunk. An input whose total length is odd
ends the stream with :invalid_input_chunk at end of input; an odd-length
chunk followed by one that completes the sample is not an error. An input
that raises ends it with :input_raised, 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.
Once the input is exhausted:
{:ok, text}— one:partial_transcriptper cumulative word prefix oftext, one:committed_transcriptoftext, then:transcription_completed. Atextwith no words emits no segment.- absent script —
:transcription_completedwithtext: "".
:transcription_completed carries the committed text trimmed (the
normative join), duration_seconds computed from the bytes consumed as
bytes / (sample_rate * 2), opts[:request_id] and request.metadata.
{:error, err}, {:events, events}, {:ok, %TranscriptionResponse{}} and
{:retry_until_call, n} behave as for ALLM.Providers.FakeSpeech's
stream callbacks; the input is not reduced.
Examples
iex> req = ALLM.TranscriptionStreamRequest.new(sample_rate: 16_000)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi there"}]]]
iex> {:ok, events} = ALLM.Providers.FakeTranscription.stream_transcribe(req, [<<0::size(256)-unit(8)>>], opts)
iex> for {:partial_transcript, %{text: t}} <- events, do: t
["hi", "hi there"]
@spec transcribe( ALLM.TranscriptionRequest.t(), keyword() ) :: {:ok, ALLM.TranscriptionResponse.t()} | {:error, ALLM.Error.TranscriptionAdapterError.t()}
Execute a scripted transcription request.
Gate order, all before the script is consulted:
adapter_opts[:capture_pid]side-channel (fires even for rejected calls).- Resolvable —
ALLM.Audio.size/1onrequest.audio. A missing file, invalid base64, or a value that is not an%ALLM.Audio{}→{:error, %TranscriptionAdapterError{reason: :invalid_request}}withmetadata.cause(:enoent,:eisdir,:invalid_base64,:invalid_source, …). - Size — more than
adapter_opts[:max_audio_bytes] || max_audio_bytes()bytes →:invalid_requestwithmetadata.countandmetadata.max. - Span flags — only when
adapter_opts[:span_flags]is present: atimestamps: trueorlogprobs: trueoutside that list →:unsupported_featurewithmetadata.field(see "Span flags" in the module docs).
Otherwise reads the script from
opts[:adapter_opts][:transcription_script], advances the process-local
cursor, and interprets the entry. An absent script produces the
default empty transcript; a spent non-empty script returns
:transcription_script_exhausted.
Propagates opts[:request_id], request.model and request.metadata
onto the response and stamps provider: :fake.
{:ok, %TranscriptionResponse{}} script entries are returned verbatim.
Examples
iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_binary("abc", "audio/mpeg"))
iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, request_id: "rid-1")
iex> {resp.text, resp.request_id, resp.usage}
{"", "rid-1", %ALLM.Usage{}}
iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_file("/nonexistent.mp3"))
iex> {:error, err} = ALLM.Providers.FakeTranscription.transcribe(req, [])
iex> {err.reason, err.metadata.cause}
{:invalid_request, :enoent}