# voice


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

This module turns a spoken answer into text. With the local engines, the
audio does not go off the machine. The web app records while you talk.
It cuts the audio into segments at your pauses and sends each segment
here. This module transcribes the segments one at a time, in order. The
text goes into the think-aloud box while you continue to talk.

These are the imports of the module.

## Engines

There are three engines. `gemma` and `mlx` run on this machine. `openai`
sends the audio to OpenAI.

- `gemma` runs a Gemma 4 model on LiteRT through the model layer. E4B is
  the default. E2B is faster.
- `mlx` runs any speech to text model that mlx-audio can load: Whisper,
  Parakeet, Qwen3-ASR, Voxtral, Canary, Moonshine and more. Install it
  with `pip install abhyasa[whisper]`.
- `openai` uses the OpenAI transcription API. It is off until you set a
  key. It sends the audio off the machine.

Each engine gets a hint: the name of the concept and its terms. Gemma
reads it in its prompt. Whisper reads it as its initial prompt, and
Qwen3-ASR as hot words. OpenAI reads it as its prompt.

The table gives the numbers on this machine (an M-series Mac), with each
model warm. “Clip A” is the English test clip of the model layer
(`rishi/nbs/speech.wav`). “Clip B” is a clip in a cloned voice with
Nyāya terms in IAST and technical terms. “Terms” is the fraction of the
9 key terms in clip B that the engine wrote correctly. “5 s” is the time
for one 5-second segment.

<table>
<colgroup>
<col style="width: 12%" />
<col style="width: 12%" />
<col style="width: 12%" />
<col style="width: 12%" />
<col style="width: 12%" />
<col style="width: 12%" />
<col style="width: 12%" />
<col style="width: 12%" />
</colgroup>
<thead>
<tr>
<th>model</th>
<th>load</th>
<th>5 s</th>
<th>clip A</th>
<th>clip B, no hint / hint</th>
<th>terms, no hint / hint</th>
<th>Sanskrit CER</th>
<th>memory</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>litert-community/gemma-4-E4B-it-litert-lm</code></td>
<td>5.0 s</td>
<td>0.69 s</td>
<td>9%</td>
<td>0% / 0%</td>
<td>9 / 9</td>
<td>12%</td>
<td>1.5 GB</td>
</tr>
<tr>
<td><code>litert-community/gemma-4-E2B-it-litert-lm</code></td>
<td>2.6 s</td>
<td>0.38 s</td>
<td>14%</td>
<td>6% / 2%</td>
<td>7 / 8</td>
<td>13%</td>
<td>1.3 GB</td>
</tr>
<tr>
<td><code>mlx-community/whisper-large-v3-turbo-asr-fp16</code></td>
<td>4.8 s</td>
<td>0.68 s</td>
<td>6%</td>
<td>4% / 0%</td>
<td>8 / 9</td>
<td>10%</td>
<td>2.3 GB</td>
</tr>
<tr>
<td><code>mlx-community/parakeet-tdt-0.6b-v3</code></td>
<td>4.9 s</td>
<td>0.15 s</td>
<td>6%</td>
<td>4% / 4%</td>
<td>7 / 7</td>
<td>Latin script</td>
<td>3.2 GB</td>
</tr>
<tr>
<td><code>mlx-community/Qwen3-ASR-0.6B-8bit</code></td>
<td>3.8 s</td>
<td>0.14 s</td>
<td>6%</td>
<td>13% / 0%</td>
<td>5 / 9</td>
<td>16%</td>
<td>2.0 GB</td>
</tr>
<tr>
<td><code>mlx-community/Qwen3-ASR-1.7B-8bit</code></td>
<td>3.6 s</td>
<td>0.25 s</td>
<td>6%</td>
<td>6% / 0%</td>
<td>7 / 9</td>
<td>14%</td>
<td>3.6 GB</td>
</tr>
<tr>
<td><code>mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit</code></td>
<td>3.7 s</td>
<td>2.21 s</td>
<td>6%</td>
<td>6% / 6%</td>
<td>7 / 7</td>
<td>22%</td>
<td>5.5 GB</td>
</tr>
<tr>
<td><code>UsefulSensors/moonshine-base</code></td>
<td>3.9 s</td>
<td>0.07 s</td>
<td>100%</td>
<td>8% / 8%</td>
<td>6 / 6</td>
<td>Latin script</td>
<td>0.8 GB</td>
</tr>
<tr>
<td><code>Mediform/canary-1b-v2-mlx-q8</code></td>
<td>4.2 s</td>
<td>0.08 s</td>
<td>6%</td>
<td>4% / 4%</td>
<td>8 / 8</td>
<td>Latin script</td>
<td>1.4 GB</td>
</tr>
<tr>
<td>Su-śrotā (NeMo, CPU, in vachaka)</td>
<td>46 s</td>
<td>0.3 s for 9 s</td>
<td>Devanagari only</td>
<td>Devanagari only</td>
<td>—</td>
<td>0%</td>
<td>1.7 GB</td>
</tr>
</tbody>
</table>

- “load” is a cold start of a cached model in a new process, with the
  Python imports. The first download takes longer.
- “memory” is the MLX peak for the `mlx` models, and the growth of the
  process for Gemma on LiteRT.
- “Sanskrit CER” is the character error rate on a 9-second Nyāya sūtra
  (from vachaka), after the vachaka normalization. vachaka chose that
  clip with Su-śrotā, so the 0% of Su-śrotā is not a fair result.
- Moonshine gave no text for the 24-second clip A. It is good only for
  short segments.
- Su-śrotā writes only Devanagari, and it needs NeMo and torch on Python
  3.12 or older. It is not an engine here. For Sanskrit speech, use
  Gemma or Whisper with the hint, or vachaka.

Gemma 4 E4B stays the default: it has no errors on clip B, it writes all
9 terms without a hint, and one 5-second segment takes 0.7 s. For more
speed, use Parakeet (0.15 s) or Qwen3-ASR 0.6B with the hint (0.14 s,
all 9 terms).

`STT_SP` is the system prompt for the Gemma engine. The other constants
are the default models. `ALIASES` keeps the old engine names: `whisper`
and `parakeet` are the `mlx` engine with a fixed model.

## Settings

The settings are in `settings.json` in
[`cfg_dir()`](https://vedicreader.github.io/abhyasa/voice.html#cfg_dir).
The web app changes them, and the next segment uses the change. You do
not have to restart. The `models` section is for the model routing of
the tutor, later. This module does not read it.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L58"
target="_blank" style="float:right; font-size:smaller">source</a>

### update_settings

``` python
def update_settings(
    section:str, **kw
)->dict:
```

*Change some keys in one section of the settings.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L50"
target="_blank" style="float:right; font-size:smaller">source</a>

### save_settings

``` python
def save_settings(
    d:dict
)->dict:
```

*Write all the settings.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L40"
target="_blank" style="float:right; font-size:smaller">source</a>

### settings

``` python
def settings()->dict:
```

*The saved settings over the defaults.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L31"
target="_blank" style="float:right; font-size:smaller">source</a>

### cfg_dir

``` python
def cfg_dir()->pathlib.Path:
```

*The folder for abhyasa’s settings and keys: `$ABHYASA_CONFIG`, else
`~/.config/abhyasa`.*

Without a file, you get the defaults. A change to one key keeps the
other keys.

``` python
test_eq(settings()['stt'], dict(engine='gemma', model='', segment=True))
update_settings('stt', segment=False)
test_eq(settings()['stt'], dict(engine='gemma', model='', segment=False))
update_settings('stt', segment=True)
```

[`current`](https://vedicreader.github.io/abhyasa/journey.html#current)
gives the engine and model in use. `$ABHYASA_STT` has priority over the
settings. It can be an engine (`gemma`), an old name (`whisper`), or an
engine and a model (`mlx:mlx-community/Qwen3-ASR-0.6B-8bit`).

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/journey.py#L73"
target="_blank" style="float:right; font-size:smaller">source</a>

### current

``` python
def current()->tuple:
```

*The engine and model in use: `$ABHYASA_STT`, else the settings.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L64"
target="_blank" style="float:right; font-size:smaller">source</a>

### resolve

``` python
def resolve(
    engine:str, model:str=''
)->tuple:
```

*The engine and model for a name: aliases become `mlx`, `engine:model`
splits, an empty model gets the default.*

The settings give Gemma E4B by default. The environment variable has
priority.

``` python
test_eq(current(), ('gemma', DFLT_GEMMA))
test_eq(resolve('parakeet'), ('mlx', DFLT_PARAKEET))
test_eq(resolve('mlx:mlx-community/Qwen3-ASR-0.6B-8bit'), ('mlx', 'mlx-community/Qwen3-ASR-0.6B-8bit'))
os.environ['ABHYASA_STT'] = 'whisper'
test_eq(current(), ('mlx', DFLT_WHISPER))
del os.environ['ABHYASA_STT']
```

## One model in memory

[`resident`](https://vedicreader.github.io/abhyasa/voice.html#resident)
keeps one speech model loaded. When the app asks for a different model,
it frees the old model first. Two large models in memory at the same
time can make the Mac slow.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L91"
target="_blank" style="float:right; font-size:smaller">source</a>

### resident

``` python
def resident(
    key, load, close:NoneType=None
):
```

*The loaded speech model for `key`: when `key` is new, free the old
model, then `load()` the new one.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L81"
target="_blank" style="float:right; font-size:smaller">source</a>

### unload

``` python
def unload():
```

*Free the loaded speech model.*

A new key frees the old model. The same key does not load again.

``` python
freed = []
resident('a', lambda: 'A', freed.append)
test_eq(resident('a', lambda: 'other'), 'A')
test_eq(resident('b', lambda: 'B'), 'B')
test_eq(freed, ['A'])
unload()
```

Each engine is a function that takes a WAV path, a hint and a model, and
returns text. Gemma keeps one LiteRT engine loaded, and makes a new
conversation for each segment. Then one segment does not change the
next.

The `mlx` engine gives the hint to a model that accepts one: Whisper as
`initial_prompt`, Qwen3-ASR as `hotwords`.

A Whisper-like model gets `initial_prompt`. A Qwen3-like model gets a
list of hot words. A model without either gets nothing.

``` python
class _W:
    def generate(self, audio, initial_prompt=None): pass
class _Q:
    def generate(self, audio, hotwords=None): pass
test_eq(_hint_kw(_W(), 'Nyāya, hetu'), dict(initial_prompt='Nyāya, hetu'))
test_eq(_hint_kw(_Q(), 'Nyāya, hetu'), dict(hotwords=['Nyāya', 'hetu']))
test_eq(_hint_kw(_W(), ''), {})
```

## OpenAI

The `openai` engine posts the WAV to the OpenAI transcription API, with
the hint as its `prompt`. `http_post` is `httpx.post` unless a test puts
a fake in its place.

The key comes from the first of these places that has `OPENAI_API_KEY`:
the environment, the abhyasa `keys.env`, then the ramabana `keys.env`
(`~/.config/ramabana/keys.env`). abhyasa only reads the ramabana file.
[`set_openai_key`](https://vedicreader.github.io/abhyasa/voice.html#set_openai_key)
writes the abhyasa file with mode 0600. An empty key removes the file.
[`masked`](https://vedicreader.github.io/abhyasa/voice.html#masked)
shows only the last 4 characters.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L169"
target="_blank" style="float:right; font-size:smaller">source</a>

### masked

``` python
def masked(
    key:str
)->str:
```

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L159"
target="_blank" style="float:right; font-size:smaller">source</a>

### set_openai_key

``` python
def set_openai_key(
    key:str=''
):
```

*Write `key` to abhyasa’s `keys.env` (mode 0600); an empty key removes
it. A key with other than letters, digits, `-` and `_` raises
`ValueError`.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L152"
target="_blank" style="float:right; font-size:smaller">source</a>

### openai_key

``` python
def openai_key()->tuple:
```

*The OpenAI key and where it is from; `('', '')` when there is none.*

A key in the abhyasa file comes back with its source, and the file has
mode 0600. When the abhyasa file has no key, abhyasa reads the ramabana
file.

``` python
RAMABANA_KEYS = Path(tempfile.mkdtemp())/'keys.env'
test_eq(openai_key(), ('', ''))
RAMABANA_KEYS.write_text('export OPENAI_API_KEY="sk-ramabana-0000"\n')
test_eq(openai_key(), ('sk-ramabana-0000', str(RAMABANA_KEYS)))
set_openai_key('sk-test-1234')
test_eq(openai_key(), ('sk-test-1234', str(cfg_dir()/'keys.env')))
test_eq(oct((cfg_dir()/'keys.env').stat().st_mode & 0o777), '0o600')
test_eq(masked('sk-test-1234'), '…1234')
```

The engine sends the model, the hint and the WAV, with the key in the
header. This test uses a fake HTTP client.

``` python
class _R:
    def raise_for_status(self): pass
    def json(self): return dict(text='hetu and sādhya')
sent = {}
def fake_post(url, **kw): sent.update(url=url, **kw); return _R()
http_post = fake_post
p = Path(tempfile.mkdtemp())/'a.wav'; p.write_bytes(b'RIFF')
test_eq(_openai(p, 'hetu'), 'hetu and sādhya')
test_eq(sent['data'], dict(model=DFLT_OPENAI, response_format='json', prompt='hetu'))
test_eq(sent['headers']['Authorization'], 'Bearer sk-test-1234')
set_openai_key('')
RAMABANA_KEYS.unlink()
test_fail(lambda: _openai(p), contains='no OpenAI key')
http_post = None
```

A key with a newline or a space can add a second line to the file. Thus
[`set_openai_key`](https://vedicreader.github.io/abhyasa/voice.html#set_openai_key)
refuses it.

``` python
test_fail(lambda: set_openai_key('sk-a\nOTHER=1'), contains='only letters')
test_fail(lambda: set_openai_key('sk a b c d e'), contains='only letters')
```

## Transcribe

[`transcribe`](https://vedicreader.github.io/abhyasa/voice.html#transcribe)
accepts a path or bytes. It writes bytes to a temporary file and removes
the file after use. Without an engine, it uses
[`current()`](https://vedicreader.github.io/abhyasa/journey.html#current),
so a change on the Settings page applies to the next segment.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L172"
target="_blank" style="float:right; font-size:smaller">source</a>

### transcribe

``` python
def transcribe(
    audio, engine:str=None, hint:str='', model:str=None
)->str:
```

*Text for a WAV file or WAV bytes.
[`hint`](https://vedicreader.github.io/abhyasa/rama.html#hint) lists
words that the speaker can use, such as the name of the concept.*

[`wav_seconds`](https://vedicreader.github.io/abhyasa/voice.html#wav_seconds)
gives the length of a clip in seconds.
[`silence`](https://vedicreader.github.io/abhyasa/voice.html#silence)
makes a clip of silence. The app uses this clip to warm a model.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L190"
target="_blank" style="float:right; font-size:smaller">source</a>

### silence

``` python
def silence(
    secs:float=0.5
)->bytes:
```

*WAV bytes of silence: 16 kHz, mono, 16 bit.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L183"
target="_blank" style="float:right; font-size:smaller">source</a>

### wav_seconds

``` python
def wav_seconds(
    audio:bytes
)->float:
```

*The length of WAV bytes, in seconds; 0 when they are not WAV.*

Half a second of silence is 0.5 seconds. Bytes that are not WAV give 0.

``` python
test_eq(wav_seconds(silence()), .5)
test_eq(wav_seconds(b'nope'), 0.)
```

## Models

`KNOWN` lists the models that we measured (see the table above). The
Settings page shows them first.
[`cached`](https://vedicreader.github.io/abhyasa/voice.html#cached)
tells if a model is in the Hugging Face cache. It does not use the
network.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L218"
target="_blank" style="float:right; font-size:smaller">source</a>

### cache_bytes

``` python
def cache_bytes(
    model:str
)->int:
```

*The bytes of `model` in the Hugging Face cache so far, with the files
that are still downloading.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L212"
target="_blank" style="float:right; font-size:smaller">source</a>

### cached

``` python
def cached(
    model:str
)->bool:
```

*True when `model` is in the Hugging Face cache.*

[`search_models`](https://vedicreader.github.io/abhyasa/voice.html#search_models)
looks up mlx-community speech to text models on Hugging Face, with their
size. It drops text to speech, aligners and diarization models, which
the `mlx` engine cannot use.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L238"
target="_blank" style="float:right; font-size:smaller">source</a>

### search_models

``` python
def search_models(
    q:str='', limit:int=15, api:NoneType=None
)->list:
```

*mlx-community speech to text models that match `q`, most downloaded
first, with their size.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L233"
target="_blank" style="float:right; font-size:smaller">source</a>

### model_size

``` python
def model_size(
    model:str, api:NoneType=None
)->int:
```

*The bytes of all the files of `model` on Hugging Face.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L228"
target="_blank" style="float:right; font-size:smaller">source</a>

### is_stt

``` python
def is_stt(
    model:str
)->bool:
```

*True when the repo name looks like a speech to text model.*

The filter keeps speech to text and drops the others.

``` python
test_eq([is_stt(m) for m in ('mlx-community/Qwen3-ASR-0.6B-8bit', 'mlx-community/whisper-tiny-asr-fp16', 'mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16', 'mlx-community/silero-vad')],
        [True, True, False, False])
```

[`fetch`](https://vedicreader.github.io/abhyasa/voice.html#fetch)
downloads a model and notes it in the settings.
[`remove`](https://vedicreader.github.io/abhyasa/voice.html#remove)
deletes a model from the cache, but only a model that
[`fetch`](https://vedicreader.github.io/abhyasa/voice.html#fetch)
downloaded. Other entries in the cache belong to other programs.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L255"
target="_blank" style="float:right; font-size:smaller">source</a>

### remove

``` python
def remove(
    model:str
):
```

*Delete `model` from the Hugging Face cache. Only a model that
[`fetch`](https://vedicreader.github.io/abhyasa/voice.html#fetch)
downloaded.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L247"
target="_blank" style="float:right; font-size:smaller">source</a>

### fetch

``` python
def fetch(
    model:str
):
```

*Download `model` into the Hugging Face cache; note it when it was not
there before.*

[`remove`](https://vedicreader.github.io/abhyasa/voice.html#remove)
refuses a model that abhyasa did not download.

``` python
test_fail(lambda: remove(DFLT_GEMMA), contains='stays')
```

## Segments in order

The browser sends one segment each time you stop to think.
[`Speech`](https://vedicreader.github.io/abhyasa/voice.html#speech) has
one worker thread for all the model calls. Thus only one model is in
memory, and the segments run one at a time, in the order they came. A
[`Dictation`](https://vedicreader.github.io/abhyasa/voice.html#dictation)
collects the segments of one answer. `next` gives the text in the order
of the segment numbers, also when a slow request arrives late. When the
browser sends a segment again (after an error), the code does not use it
two times.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L267"
target="_blank" style="float:right; font-size:smaller">source</a>

### Dictation

``` python
def Dictation(
    speech, hint:str=''
):
```

*The segments of one spoken answer: each is transcribed on the speech
worker, and the text comes out in order.*

[`Speech`](https://vedicreader.github.io/abhyasa/voice.html#speech) owns
the worker. `warm` loads the model in use with half a second of silence,
so the first segment does not wait for it. `use` changes the engine and
model, saves the change, and warms the new model.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/abhyasa/blob/main/abhyasa/voice.py#L303"
target="_blank" style="float:right; font-size:smaller">source</a>

### Speech

``` python
def Speech(
    fn:NoneType=None
):
```

*One worker thread for every speech model call, and the open
dictations.*

### Tests with a fake engine

This fake engine is slow on even segments, so the requests finish out of
order. The text must still come out in order.

``` python
calls = []
def slow_fake(audio, hint=''):
    n = int(audio.decode())
    calls.append(n)
    time.sleep(.05 if n % 2 == 0 else 0)
    return f'segment {n} ({hint})'
sp = Speech(slow_fake)
d = sp.runs[sp.start('hetu')]
```

Segments 0 to 5 arrive out of order, from several threads. Segment 3
comes two times.

``` python
order = [2, 0, 1, 3, 3, 5, 4]
with ThreadPoolExecutor(4) as ex: added = list(ex.map(lambda n: d.add(n, str(n).encode()), order))
test_eq(sum(added), 6)
d.stop(6)
out = []
while not (r := d.next(5)).get('done'): out.append(r)
test_eq([r['seq'] for r in out], list(range(6)))
test_eq(out[0]['text'], 'segment 0 (hetu)')
test_eq(sorted(calls), list(range(6)))
```

All the model calls ran on the one worker thread, one at a time.

``` python
seen = set()
sp.submit(lambda: seen.add(threading.current_thread().name)).result()
test_eq(len(seen), 1)
test_eq(d.pending, 0)
```

An engine error gives an empty text with the error. It does not stop the
segments after it.

``` python
def flaky(audio, hint=''):
    if audio == b'bad': raise RuntimeError('no model')
    return 'ok'
sp = Speech(flaky)
d = sp.runs[sp.start()]
d.add(0, b'bad'); d.add(1, b'good'); d.stop(2)
test_eq(d.next(5), dict(seq=0, text='', error='no model'))
test_eq(d.next(5), dict(seq=1, text='ok'))
test_eq(d.next(5), dict(done=True))
```

Without `stop`, `next` waits and then gives None. `use` saves the choice
and warms the new model with silence.

``` python
d = sp.runs[sp.start()]
test_is(d.next(.05), None)
heard = []
sp = Speech(lambda a, hint='': heard.append(wav_seconds(a)) or '')
sp.use('parakeet').result()
test_eq(current(), ('mlx', DFLT_PARAKEET))
test_eq(heard, [.5])
assert sp.status.startswith('ready'), sp.status
update_settings('stt', engine='gemma', model='')
```

## Real models

These tests use a real clip and real models. They are slow. Run them
with `nbdev-test --flags slow`.

``` python
clip = first(p for p in (Path('../../rishi/nbs/speech.wav'), Path('../../../rishi/nbs/speech.wav')) if p.exists())   # from the repo or a worktree
if clip:
    for e in ('gemma', 'parakeet', 'mlx:mlx-community/Qwen3-ASR-0.6B-8bit'): print(e, transcribe(clip, e, hint='Tennyson'))
    unload()
```
