update_settings
def update_settings(
section:str, **kw
)->dict:Change some keys in one section of the settings.
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.
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.
| model | load | 5 s | clip A | clip B, no hint / hint | terms, no hint / hint | Sanskrit CER | memory |
|---|---|---|---|---|---|---|---|
litert-community/gemma-4-E4B-it-litert-lm |
5.0 s | 0.69 s | 9% | 0% / 0% | 9 / 9 | 12% | 1.5 GB |
litert-community/gemma-4-E2B-it-litert-lm |
2.6 s | 0.38 s | 14% | 6% / 2% | 7 / 8 | 13% | 1.3 GB |
mlx-community/whisper-large-v3-turbo-asr-fp16 |
4.8 s | 0.68 s | 6% | 4% / 0% | 8 / 9 | 10% | 2.3 GB |
mlx-community/parakeet-tdt-0.6b-v3 |
4.9 s | 0.15 s | 6% | 4% / 4% | 7 / 7 | Latin script | 3.2 GB |
mlx-community/Qwen3-ASR-0.6B-8bit |
3.8 s | 0.14 s | 6% | 13% / 0% | 5 / 9 | 16% | 2.0 GB |
mlx-community/Qwen3-ASR-1.7B-8bit |
3.6 s | 0.25 s | 6% | 6% / 0% | 7 / 9 | 14% | 3.6 GB |
mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit |
3.7 s | 2.21 s | 6% | 6% / 6% | 7 / 7 | 22% | 5.5 GB |
UsefulSensors/moonshine-base |
3.9 s | 0.07 s | 100% | 8% / 8% | 6 / 6 | Latin script | 0.8 GB |
Mediform/canary-1b-v2-mlx-q8 |
4.2 s | 0.08 s | 6% | 4% / 4% | 8 / 8 | Latin script | 1.4 GB |
| Su-śrotā (NeMo, CPU, in vachaka) | 46 s | 0.3 s for 9 s | Devanagari only | Devanagari only | — | 0% | 1.7 GB |
mlx models, and the growth of the process for Gemma on LiteRT.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.
The settings are in settings.json in 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.
Change some keys in one section of the settings.
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.
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).
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.
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']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.
The loaded speech model for key: when key is new, free the old model, then load() the new one.
A new key frees the old model. The same key does not load again.
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.
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 writes the abhyasa file with mode 0600. An empty key removes the file. masked shows only the last 4 characters.
Write key to abhyasa’s keys.env (mode 0600); an empty key removes it. A key with other than letters, digits, - and _ raises ValueError.
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.
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.
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 = NoneA key with a newline or a space can add a second line to the file. Thus set_openai_key refuses it.
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(), so a change on the Settings page applies to the next segment.
Text for a WAV file or WAV bytes. hint lists words that the speaker can use, such as the name of the concept.
wav_seconds gives the length of a clip in seconds. silence makes a clip of silence. The app uses this clip to warm a model.
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.
KNOWN lists the models that we measured (see the table above). The Settings page shows them first. cached tells if a model is in the Hugging Face cache. It does not use the network.
The bytes of model in the Hugging Face cache so far, with the files that are still downloading.
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.
mlx-community speech to text models that match q, most downloaded first, with their size.
The bytes of all the files of model on Hugging Face.
True when the repo name looks like a speech to text model.
The filter keeps speech to text and drops the others.
fetch downloads a model and notes it in the settings. remove deletes a model from the cache, but only a model that fetch downloaded. Other entries in the cache belong to other programs.
Delete model from the Hugging Face cache. Only a model that fetch downloaded.
Download model into the Hugging Face cache; note it when it was not there before.
remove refuses a model that abhyasa did not download.
The browser sends one segment each time you stop to think. 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 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.
The segments of one spoken answer: each is transcribed on the speech worker, and the text comes out in order.
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.
One worker thread for every speech model call, and the open dictations.
This fake engine is slow on even segments, so the requests finish out of order. The text must still come out in order.
Segments 0 to 5 arrive out of order, from several threads. Segment 3 comes two times.
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.
An engine error gives an empty text with the error. It does not stop the segments after it.
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.
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='')These tests use a real clip and real models. They are slow. Run them with nbdev-test --flags slow.