diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 268b768..5877a23 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
- uses: actions/checkout@v5
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Run clippy
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
+ run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
test:
name: Test
@@ -58,7 +58,7 @@ jobs:
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --locked
- name: Generate coverage summary
- run: cargo llvm-cov --workspace --json --summary-only --output-path coverage-summary.json --fail-under-lines 88
+ run: cargo llvm-cov --workspace --locked --json --summary-only --output-path coverage-summary.json --fail-under-lines 88
- name: Update coverage badge data
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
@@ -90,3 +90,59 @@ jobs:
git add badges/coverage.json
git commit -m "Update coverage badge"
git push
+
+ docs_and_scripts:
+ name: Documentation and setup scripts
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - name: Check documentation links and setup script tests
+ run: |
+ python3 scripts/check-doc-links.py
+ python3 -m unittest discover -s scripts/tests -v
+ for script in scripts/*.sh; do bash -n "$script"; done
+
+ source_install:
+ name: Source install and real-model smoke test
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions-rust-lang/setup-rust-toolchain@v1
+ - name: Install service from source
+ run: cargo install --locked --path crates/aximo --root "$RUNNER_TEMP/aximo-install"
+ - name: Download default model
+ run: ./scripts/fetch-models.sh
+ - name: Verify installed service with real inference
+ env:
+ AXIMO_CONFIG: config/aximo.local.toml
+ AXIMO_SERVER_PORT: "18080"
+ ORT_LOG: error
+ run: |
+ "$RUNNER_TEMP/aximo-install/bin/aximo" > service.log 2>&1 &
+ service_pid=$!
+ trap 'kill "$service_pid" 2>/dev/null || true; wait "$service_pid" 2>/dev/null || true' EXIT
+ ready=false
+ for attempt in $(seq 1 120); do
+ if ! kill -0 "$service_pid" 2>/dev/null; then
+ cat service.log
+ exit 1
+ fi
+ if curl --fail --silent http://127.0.0.1:18080/health/ready > /dev/null; then
+ ready=true
+ break
+ fi
+ sleep 2
+ done
+ if [ "$ready" != true ]; then
+ cat service.log
+ exit 1
+ fi
+ python3 scripts/smoke-test.py --base-url http://127.0.0.1:18080
+ - name: Upload service startup log
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: source-install-service-log
+ path: service.log
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 9f38681..db8c70b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
target
.DS_Store
var
+
+__pycache__/
diff --git a/Cargo.lock b/Cargo.lock
index 22d9100..c78de1f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -19,9 +19,9 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.102"
+version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
@@ -319,9 +319,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "der"
-version = "0.8.0"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
+checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
dependencies = [
"pem-rfc7468",
"zeroize",
diff --git a/Cargo.toml b/Cargo.toml
index 6acee2d..8d63319 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,7 @@ version = "0.1.0"
aximo-audio = { path = "crates/aximo-audio", version = "0.1.0" }
aximo-core = { path = "crates/aximo-core", version = "0.1.0" }
aximo-inference = { path = "crates/aximo-inference", version = "0.1.0" }
-anyhow = "1.0.102"
+anyhow = "1.0.103"
axum = { version = "0.8.9", features = ["ws"] }
bytes = "1.11.1"
futures-util = "0.3.32"
diff --git a/README.md b/README.md
index aa834eb..9183295 100644
--- a/README.md
+++ b/README.md
@@ -1,418 +1,65 @@
# Aximo
-[](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml)
-[](https://github.com/agent-axiom/aximo/actions/workflows/security.yml)
-[](https://github.com/agent-axiom/aximo/actions/workflows/image-security.yml)
-[](https://github.com/agent-axiom/aximo/actions/workflows/container.yml)
+[](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml)
+[](https://github.com/agent-axiom/aximo/actions/workflows/security.yml)
+[](https://github.com/agent-axiom/aximo/actions/workflows/image-security.yml)
+[](https://github.com/agent-axiom/aximo/actions/workflows/container.yml)
[](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml)
-[](LICENSE)
-`aximo` is a CPU-first STT microservice for Russian and English built as a Rust Cargo workspace. It exposes:
+Local, CPU-first speech-to-text API in Rust. Run your own Parakeet or GigaAM models, transcribe audio over HTTP, and receive partial/final results over WebSocket. No cloud transcription account is required.
-- `POST /v1/transcriptions` for short audio
-- `GET /v1/realtime` for realtime WebSocket streaming
-- `GET /v1/capabilities` for active engine capability metadata
-- `GET /health/live` and `GET /health/ready` for liveness/readiness
-- `GET /openapi.json` for the OpenAPI schema
-- `GET /docs/` for Swagger UI with a built-in microphone recorder panel
-- `GET /metrics` for Prometheus-compatible operational metrics
+- HTTP API for WAV, MP3, FLAC, M4A/MP4, and raw PCM
+- Browser microphone recorder at `/docs/`
+- Health checks, capability discovery, and Prometheus metrics
+- English and Russian support depends on the selected model; check `/v1/capabilities`
-## Live Demo
+The bundled adapters use bounded buffered realtime, not native incremental decoding. See [capabilities and limits](docs/api-reference.md#capabilities).
-Try Aximo in your browser:
+## Quick start
-https://ifif-aximo.hf.space/docs
-
-The demo runs on a free CPU Hugging Face Space, so cold starts and slower inference are expected.
-
-## Workspace
-
-- `crates/aximo`: HTTP and WebSocket service binary
-- `crates/aximo-core`: scheduler and shared STT domain types
-- `crates/aximo-inference`: `transcribe-rs` adapters for local CPU models
-- `crates/aximo-audio`: audio helpers
-
-## How It Works
-
-```mermaid
-flowchart LR
- client["Client"]
- recorder["Swagger microphone recorder"]
- api["Aximo API
axum HTTP + WebSocket"]
- short["POST /v1/transcriptions
short audio"]
- realtime["GET /v1/realtime
WebSocket"]
- admission["Admission control
body limits, capacity, degraded policy"]
- audio["Audio preprocessing
MIME parse, decode, normalize, resample"]
- session["Realtime session manager
bounded PCM buffer, cadence, cleanup"]
- scheduler["Scheduler semaphores
request/session/inference limits"]
- gate["Model execution gate
one model instance, one execution slot"]
- blocking["Blocking inference worker
timeout-bounded client contract"]
- native["Native streaming worker
only when backend supports it"]
- engine["Local CPU STT engine
transcribe-rs ONNX adapter"]
- response["JSON / WebSocket events
text, timings, optional segments"]
- ops["Operations surface
health, readiness, metrics, capabilities"]
-
- client --> api
- recorder --> api
- api --> short
- api --> realtime
-
- short --> admission --> audio --> scheduler --> gate --> blocking --> engine --> response
- realtime --> admission --> session
- session --> scheduler
- scheduler --> gate
- gate --> blocking
- gate --> native
- native --> engine
- blocking --> engine
- engine --> response
-
- api --> ops
- blocking --> ops
- native --> ops
- scheduler --> ops
-```
-
-Architecture and protocol details live in:
-
-- [docs/architecture.md](docs/architecture.md)
-- [docs/client-examples.md](docs/client-examples.md)
-- [docs/realtime-protocol.md](docs/realtime-protocol.md)
-- [docs/model-licenses.md](docs/model-licenses.md)
-- [docs/benchmarks.md](docs/benchmarks.md)
-- [docs/benchmark-baselines.md](docs/benchmark-baselines.md)
-- [docs/kubernetes.md](docs/kubernetes.md)
-- [docs/deployment-security.md](docs/deployment-security.md)
-
-## Models
-
-Models are runtime artifacts and must live outside git. The service expects a model root directory configured via [config/aximo.example.toml](config/aximo.example.toml).
-
-Compatible model bundles for the current `transcribe-rs` integration:
-
-- Parakeet int8 ONNX bundle: [blob.handy.computer/parakeet-v3-int8.tar.gz](https://blob.handy.computer/parakeet-v3-int8.tar.gz)
-- Parakeet int8 ONNX bundle on Hugging Face: [istupakov/parakeet-tdt-0.6b-v3-onnx](https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/tree/main)
-- GigaAM v3 ONNX bundle on Hugging Face: [istupakov/gigaam-v3-onnx](https://huggingface.co/istupakov/gigaam-v3-onnx/tree/main)
-
-Example layout:
-
-```text
-/var/lib/aximo/models/
-├── parakeet-tdt-0.6b-v3-int8/
-└── giga-am-v3/
-```
-
-## Quick Start
-
-Download the default `Parakeet` model bundle:
-
-```bash
-just setup-models
-```
-
-or directly:
+With [Docker Compose](https://docs.docker.com/compose/install/), Git, Bash, curl, and tar installed:
```bash
+git clone https://github.com/agent-axiom/aximo.git
+cd aximo
./scripts/fetch-models.sh
-```
-
-### Docker Compose
-
-After the model is downloaded to `./var/models`:
-
-```bash
docker compose up --build
```
-This uses [docker-compose.yml](docker-compose.yml), mounts `./var/models` into the container, and serves the API on `http://127.0.0.1:8080`.
-
-## Local Run
+The first run downloads the Parakeet model and builds the service. Open [http://127.0.0.1:8080/docs/](http://127.0.0.1:8080/docs/) after it reports ready.
-For local non-Docker usage, use [config/aximo.local.toml](config/aximo.local.toml), which points to `./var/models`:
+Already have Rust and a native build toolchain? From the checkout:
```bash
-AXIMO_CONFIG=config/aximo.local.toml cargo run -p aximo
-```
-
-For containerized usage, [config/aximo.example.toml](config/aximo.example.toml) remains the default and expects models at `/var/lib/aximo/models`.
-
-## Configuration
-
-`AXIMO_CONFIG` points to a TOML config file. If it is not set, built-in defaults are used. Individual fields can be overridden with environment variables after the TOML file is loaded, which is useful for Docker and Kubernetes deployments:
-
-```bash
-AXIMO_SERVER_HOST=0.0.0.0
-AXIMO_SERVER_PORT=8080
-AXIMO_SHUTDOWN_GRACE_PERIOD_MS=30000
-AXIMO_MODELS_DIR=/var/lib/aximo/models
-AXIMO_DEFAULT_OFFLINE_ENGINE=parakeet
-AXIMO_DEFAULT_REALTIME_ENGINE=parakeet
-AXIMO_MAX_SHORT_AUDIO_REQUESTS=8
-AXIMO_MAX_SHORT_AUDIO_BYTES=25000000
-AXIMO_MAX_SHORT_RAW_PCM_BYTES=1920000
-AXIMO_MAX_SHORT_AUDIO_DURATION_MS=60000
-AXIMO_MAX_SHORT_DECODED_SAMPLES=5760000
-AXIMO_MAX_REALTIME_SESSIONS=24
-AXIMO_MAX_SHORT_INFERENCES=1
-AXIMO_MAX_REALTIME_INFERENCES=1
-AXIMO_MAX_REALTIME_SESSION_BYTES=1920000
-AXIMO_MAX_REALTIME_SESSION_DURATION_MS=60000
-AXIMO_REALTIME_PARTIAL_MIN_INTERVAL_MS=300
-AXIMO_REALTIME_PARTIAL_MIN_CHUNK_BYTES=9600
-AXIMO_REALTIME_EVENT_CHANNEL_CAPACITY=64
-AXIMO_SHORT_INFERENCE_TIMEOUT_MS=120000
-AXIMO_REALTIME_PARTIAL_TIMEOUT_MS=5000
-AXIMO_REALTIME_FINAL_TIMEOUT_MS=120000
-AXIMO_RUNTIME_DEGRADE_AFTER_CONSECUTIVE_FAILURES=3
-AXIMO_RUNTIME_DEGRADED_POLICY=readiness_only
-AXIMO_RUNTIME_DEGRADED_RECOVERY_COOLDOWN_MS=30000
-```
-
-## Capabilities
-
-`GET /v1/capabilities` reports the active offline and realtime engine contract. Use this endpoint before relying on optional response metadata or native streaming behavior:
-
-```bash
-curl http://127.0.0.1:8080/v1/capabilities
-```
-
-Example response:
-
-```json
-{
- "offline": {
- "configured_engine": "parakeet",
- "mode": "offline",
- "model": {
- "engine": "parakeet",
- "model_name": "Parakeet",
- "sample_rate_hz": 16000,
- "languages": ["en"],
- "supports_timestamps": true,
- "supports_language_detection": false,
- "supports_native_streaming": false
- }
- },
- "realtime": {
- "configured_engine": "parakeet",
- "mode": "bounded_buffered_offline",
- "model": {
- "engine": "parakeet",
- "model_name": "Parakeet",
- "sample_rate_hz": 16000,
- "languages": ["en"],
- "supports_timestamps": true,
- "supports_language_detection": false,
- "supports_native_streaming": false
- }
- }
-}
-```
-
-For the current local ONNX adapters, Parakeet reports English with timestamps and GigaAM reports Russian without timestamps. Neither exposes language detection or native incremental streaming through `transcribe-rs` 0.3.x. Aximo now has a backend extension point for native streaming sessions and automatically switches the realtime WebSocket path to it when the configured backend reports `supports_native_streaming=true`; the bundled Parakeet/GigaAM adapters correctly stay on bounded buffered realtime.
-
-## Short Audio Example
-
-Short transcription currently accepts:
-
-- `audio/wav`
-- `audio/mpeg`
-- `audio/flac`
-- `audio/mp4`
-- `audio/x-m4a`
-- `audio/pcm`
-- `application/octet-stream`
-
-Compressed/container formats are decoded and normalized before inference. `audio/pcm` and `application/octet-stream` are still interpreted as raw `pcm_s16le`, `16 kHz`, mono audio. Short-audio ingest is bounded by HTTP body size, raw PCM byte size, decoded sample count, and decoded duration; limit violations return `413 Payload Too Large`.
-Short-audio inference is also bounded by `short_inference_timeout_ms`; timeout responses use `504 Gateway Timeout` with code `inference_timeout`.
-
-```bash
-curl -X POST http://127.0.0.1:8080/v1/transcriptions \
- -H 'content-type: audio/wav' \
- --data-binary @sample.wav
-```
-
-Optional query parameters are accepted for API compatibility and forwarded to the engine request:
-
-- `engine`: must match the configured short-audio engine for this service instance, for example `parakeet`.
-- `language` or `language_hint`: optional backend language hint such as `ru`, `en`, or `auto`; `language_hint` wins when both are supplied.
-- `timestamps`: requests timestamp metadata when the backend supports it. Parakeet can return backend-provided segments; GigaAM may still return an empty `segments` array.
-
-```bash
-curl -X POST 'http://127.0.0.1:8080/v1/transcriptions?engine=parakeet&language=ru×tamps=true' \
- -H 'content-type: audio/wav' \
- --data-binary @sample.wav
-```
-
-Example response:
-
-```json
-{
- "text": "hello world",
- "segments": [],
- "detected_language": null,
- "engine": "parakeet",
- "duration_ms": 1000,
- "processing_ms": 37
-}
-```
-
-With the current `transcribe-rs` ONNX adapters used here, `detected_language` is `null` when language detection is not exposed. `segments` is populated only when `timestamps=true` and the selected backend returns real segment metadata. `duration_ms` and `processing_ms` are measured values and vary per request.
-
-Error responses from `POST /v1/transcriptions` are structured JSON:
-
-```json
-{
- "code": "invalid_audio",
- "message": "invalid audio payload: pcm payload must be aligned to 16-bit samples"
-}
-```
-
-Unsupported short-audio media types return `415 Unsupported Media Type` with code `unsupported_media_type`. Malformed payloads for supported media types remain `400 invalid_audio`.
-
-## Realtime Example
-
-Realtime uses WebSocket and raw `pcm_s16le`, `16 kHz`, mono binary chunks. If `/v1/capabilities` reports `supports_native_streaming=true`, the WebSocket handler creates a stateful native streaming session and routes chunk/final calls through a bounded native streaming worker with timeout and backpressure handling, so backend calls do not run directly inside the WebSocket loop. Otherwise, Aximo uses bounded buffered realtime.
-For bounded buffered realtime, partial hypotheses are computed from a bounded rolling recent window and use latest-wins coalescing under load, so they favor freshness over a steady partial cadence. The final transcription on `stop` waits for the realtime inference slot and runs over the full bounded session buffer.
-Admission limits and inference limits are configured separately: `max_short_audio_requests` and `max_realtime_sessions` bound accepted work, while `max_short_inferences` and `max_realtime_inferences` bound per-path inference admission. Actual backend execution is additionally protected by a per-engine model gate, shared when offline and realtime reuse the same engine instance.
-Current CPU model execution is safety-first: one loaded model instance has one execution slot. Increase throughput by running more service replicas or, in a future worker-pool design, by loading multiple model replicas.
-Realtime server events are sent through a bounded per-socket queue; clients that stop reading can be disconnected instead of accumulating unbounded memory.
-Realtime chunks must be aligned to `pcm_s16le` sample width; odd-length binary frames return `invalid_audio_chunk`.
-Realtime partial and final inference have separate timeout budgets. A timeout returns an `inference_timeout` event, but the underlying blocking backend call may continue until it returns because Rust cannot safely kill that OS thread. The per-engine model gate stays held until that backend call actually exits, so timed-out calls cannot admit unlimited follow-up backend executions. Native streaming health is tracked separately for stream start, partial chunk handling, and finalization through `realtime_stream:`, `realtime_partial:`, and `realtime_final:`.
-
-```js
-const ws = new WebSocket("ws://127.0.0.1:8080/v1/realtime");
-ws.binaryType = "arraybuffer";
-
-ws.addEventListener("message", (event) => {
- console.log("server:", event.data);
-});
-
-ws.addEventListener("open", async () => {
- ws.send(JSON.stringify({ event: "start" }));
-
- const pcmChunk = new Uint8Array([0, 0, 1, 0, 2, 0, 3, 0]);
- ws.send(pcmChunk);
-
- ws.send(JSON.stringify({ event: "stop" }));
-});
-```
-
-Expected server events:
-
-- `session_started`
-- `partial`
-- `final`
-- `error`
-
-`error` events now include machine-readable `code` and human-readable `reason`, for example:
-
-```json
-{
- "event": "error",
- "code": "realtime_capacity_exhausted",
- "reason": "realtime session capacity exhausted"
-}
+./scripts/fetch-models.sh
+cargo build --release --locked -p aximo
+AXIMO_CONFIG=config/aximo.local.toml ./target/release/aximo
```
-## API Docs
-
-After the service starts:
-
-- Swagger UI: [http://127.0.0.1:8080/docs/](http://127.0.0.1:8080/docs/)
-- OpenAPI JSON: [http://127.0.0.1:8080/openapi.json](http://127.0.0.1:8080/openapi.json)
-- Metrics: [http://127.0.0.1:8080/metrics](http://127.0.0.1:8080/metrics)
-- Client examples: [docs/client-examples.md](docs/client-examples.md)
-
-The `/docs/` page also includes an `Aximo Recorder` panel that can capture microphone audio in the browser:
-
-- `Short Audio` records locally, converts to WAV, and sends the result to `POST /v1/transcriptions`
-- `Realtime` downsamples to `pcm_s16le 16 kHz mono` and streams binary chunks to `GET /v1/realtime`
-
-For browser microphone access, use `localhost`, `127.0.0.1`, or HTTPS.
-
-One notable addition: I extended Swagger to support sending recordings directly from the microphone.
-
-
-
-## Troubleshooting
-
-If container logs include `onnxruntime cpuid_info warning: Unknown CPU vendor`, this is typically an ONNX Runtime CPU detection warning on ARM or virtualized environments, not a model-load failure. The container now sets `ORT_LOG=error` to reduce that noise in normal runs.
-
-## Metrics
+See [installation](docs/installation.md) for prerequisites, installing the binary, model setup, and verification.
-`GET /metrics` exposes lightweight Prometheus text metrics for operational visibility:
+## Transcribe a file
-- `aximo_http_requests_total{status,code}`
-- `aximo_errors_total{code}`
-- `aximo_audio_body_bytes_total`
-- `aximo_audio_decode_seconds_bucket/sum/count`
-- `aximo_audio_duration_seconds_bucket/sum/count`
-- `aximo_inference_wait_seconds_bucket/sum/count{kind}`
-- `aximo_model_execution_wait_seconds_bucket/sum/count{kind}`
-- `aximo_inference_seconds_bucket/sum/count{kind}`
-- `aximo_rtf_bucket/sum/count{kind}`
-- `aximo_inference_timeouts_total{kind}`
-- `aximo_blocking_tasks_active`
-- `aximo_model_executions_active`
-- `aximo_runtime_degraded`
-- `aximo_runtime_consecutive_failures`
-- `aximo_runtime_component_degraded{component}`
-- `aximo_runtime_component_consecutive_failures{component}`
-- `aximo_ws_sessions_active`
-- `aximo_ws_queue_overflows_total`
-- `aximo_realtime_partial_coalesced_total`
-- `aximo_realtime_stale_partial_skips_total`
-- `aximo_model_execution_wait_timeouts_total`
-
-Latency and RTF metrics are emitted as Prometheus histograms, so dashboards can use `histogram_quantile()` for p95/p99 without depending only on averages.
-
-`/health/live` is process liveness. `/health/ready` reports aggregate readiness and per-component details such as `short:parakeet`, `realtime_partial:parakeet`, and `realtime_final:parakeet`. It returns `503` with a JSON `degraded` status after consecutive timeout/runtime/unavailable inference failures for any component reach `runtime_degrade_after_consecutive_failures`. A successful inference clears only its own component state. `runtime_degraded_policy = "readiness_only"` only signals orchestrators through readiness; `runtime_degraded_policy = "fail_fast_inference"` additionally rejects new inference work for degraded components with `engine_degraded`, then allows one half-open recovery probe after `runtime_degraded_recovery_cooldown_ms`. Client-side errors that stop a half-open probe before inference consume the probe window and restart the cooldown without changing the prior engine failure reason.
-
-On SIGINT or SIGTERM, Aximo notifies active websocket handlers, sends close frames, stops accepting new connections through axum graceful shutdown, and waits up to `shutdown_grace_period_ms`.
-
-## Known Limits
-
-- Realtime uses a native streaming session only when `/v1/capabilities` reports `supports_native_streaming=true`; Parakeet and GigaAM currently report `false`, so they intentionally use bounded buffered realtime.
-- Native streaming currently uses one native worker thread per active native streaming session. This keeps backend calls out of the WebSocket loop, but high native-streaming session counts must be benchmarked before raising `max_realtime_sessions`.
-- `segments` is backend-dependent and only returned when `timestamps=true`; `detected_language` stays `null` while `/v1/capabilities` reports `supports_language_detection=false`.
-- Container decode now avoids an extra input-buffer copy from axum `Bytes`, but decoded samples are still materialized in memory before normalization.
-- Audio resampling now uses a bounded windowed-sinc path, but production WER/CER work should still validate preprocessing quality against real audio.
-- Remaining product work is backend-driven: plug in a backend that exposes language detection when that capability is required.
-
-## Development
-
-Common checks:
+In another terminal, send an existing audio file:
```bash
-just fmt
-just lint
-just test
-just coverage
-just setup-models
+curl --fail-with-body http://127.0.0.1:8080/v1/transcriptions \
+ -H 'Content-Type: audio/wav' --data-binary @sample.wav
```
-## Container Releases
-
-The `Container` GitHub Actions workflow publishes `ghcr.io/agent-axiom/aximo` on pushes to `main`, semantic version tags such as `v0.1.0`, and manual dispatch. Tags include branch, semver, major/minor, and git SHA variants. The image intentionally does not include model files; mount a model directory and provide `AXIMO_CONFIG` as shown in the Docker Compose example.
-Container builds request BuildKit SBOM and provenance attestations.
-
-## Security
-
-Security reporting is documented in [SECURITY.md](SECURITY.md). The `Security` GitHub Actions workflow runs `cargo audit`, `cargo deny check`, and CycloneDX SBOM generation.
-
-Aximo should not be exposed directly to untrusted clients without an authenticated ingress or API gateway. Use [docs/deployment-security.md](docs/deployment-security.md) for ingress authentication, mTLS/API-key/JWT options, and rate limiting guidance.
-
-## crates.io
+The JSON response contains `text`, `segments`, `engine`, and timing metadata. For voice input in your terminal or Claude Code, see [terminal voice integration](docs/terminal-voice.md).
-The publishable library crates are:
+## Documentation
-- `aximo-core`
-- `aximo-audio`
-- `aximo-inference`
+- [Installation and first-run checks](docs/installation.md)
+- [API reference and browser recorder](docs/api-reference.md) · [client examples](docs/client-examples.md) · [realtime protocol](docs/realtime-protocol.md)
+- [Configuration](docs/configuration.md) · [operations and troubleshooting](docs/operations.md)
+- [Architecture](docs/architecture.md) · [development and CI](docs/development.md)
+- [Model licenses](docs/model-licenses.md) · [benchmarks](docs/benchmarks.md) · [benchmark baselines](docs/benchmark-baselines.md)
+- [Deployment security](docs/deployment-security.md) · [Kubernetes](docs/kubernetes.md) · [publishing](docs/publishing.md)
-The `aximo` service crate is intentionally marked `publish = false`.
+## Demo and security
-Use `just package-libs` for the local pre-publish check of `aximo-core` and `aximo-audio`. `aximo-inference` must be dry-run published only after `aximo-core` is already available in the `crates.io` index.
+Try the [public browser demo](https://ifif-aximo.hf.space/docs). It may cold-start and run slowly; only upload audio you are comfortable sending to that public service. Use a local installation for private audio.
-Release workflow notes are documented in [docs/publishing.md](docs/publishing.md).
+Aximo has no built-in authentication. Keep it on loopback or behind an authenticated gateway before exposing it to other machines. See [deployment security](docs/deployment-security.md) and [security reporting](SECURITY.md).
diff --git a/crates/aximo/tests/workspace_smoke.rs b/crates/aximo/tests/workspace_smoke.rs
index 886089a..5c091c9 100644
--- a/crates/aximo/tests/workspace_smoke.rs
+++ b/crates/aximo/tests/workspace_smoke.rs
@@ -46,13 +46,16 @@ fn workspace_docs_keep_transcription_query_examples_valid() {
.nth(2)
.expect("workspace root");
let readme = std::fs::read_to_string(root.join("README.md")).unwrap();
+ let api = std::fs::read_to_string(root.join("docs/api-reference.md")).unwrap();
let architecture = std::fs::read_to_string(root.join("docs/architecture.md")).unwrap();
let benchmark = std::fs::read_to_string(root.join("scripts/benchmark-api.sh")).unwrap();
let typo = ["×", "tamps"].concat();
- assert!(readme.contains("language=ru×tamps=true"));
+ assert!(readme.contains("docs/api-reference.md"));
+ assert!(api.contains("language=ru×tamps=true"));
assert!(benchmark.contains(r#"engine=${engine}×tamps=${TIMESTAMPS}"#));
assert!(!readme.contains(&typo));
+ assert!(!api.contains(&typo));
assert!(!architecture.contains(&typo));
assert!(!benchmark.contains(&typo));
}
@@ -143,15 +146,20 @@ fn workspace_documents_backend_capability_closure() {
.nth(2)
.expect("workspace root");
let readme = std::fs::read_to_string(root.join("README.md")).unwrap();
+ let api = std::fs::read_to_string(root.join("docs/api-reference.md")).unwrap();
let architecture = std::fs::read_to_string(root.join("docs/architecture.md")).unwrap();
let realtime_protocol =
std::fs::read_to_string(root.join("docs/realtime-protocol.md")).unwrap();
- assert!(readme.contains("backend extension point for native streaming sessions"));
- assert!(readme.contains("bounded native streaming worker"));
- assert!(readme.contains("one native worker thread per active native streaming session"));
- assert!(readme.contains("bounded windowed-sinc"));
- assert!(readme.contains("supports_language_detection=false"));
+ let operations = std::fs::read_to_string(root.join("docs/operations.md")).unwrap();
+
+ assert!(readme.contains("docs/api-reference.md"));
+ assert!(readme.contains("docs/operations.md"));
+ assert!(api.contains("backend extension point for native streaming sessions"));
+ assert!(api.contains("bounded native streaming worker"));
+ assert!(operations.contains("one native worker thread per active native streaming session"));
+ assert!(operations.contains("bounded windowed-sinc"));
+ assert!(operations.contains("supports_language_detection=false"));
assert!(architecture.contains("start_streaming_session()"));
assert!(architecture.contains("Native Streaming Worker"));
assert!(architecture.contains("realtime_stream:"));
diff --git a/docker-compose.yml b/docker-compose.yml
index 00e1af9..cde3589 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,7 +8,7 @@ services:
AXIMO_CONFIG: /app/config/aximo.toml
ORT_LOG: error
ports:
- - "8080:8080"
+ - "127.0.0.1:8080:8080"
volumes:
- ./config/aximo.example.toml:/app/config/aximo.toml:ro
- ./var/models:/var/lib/aximo/models:ro
diff --git a/docs/api-reference.md b/docs/api-reference.md
new file mode 100644
index 0000000..baceb61
--- /dev/null
+++ b/docs/api-reference.md
@@ -0,0 +1,168 @@
+# API reference
+
+See [installation](installation.md) to start Aximo, [client examples](client-examples.md) for integrations, and [realtime protocol](realtime-protocol.md) for the full WebSocket contract.
+
+## Capabilities
+
+`GET /v1/capabilities` reports the active offline and realtime engine contract. Use this endpoint before relying on optional response metadata or native streaming behavior:
+
+```bash
+curl http://127.0.0.1:8080/v1/capabilities
+```
+
+Example response:
+
+```json
+{
+ "offline": {
+ "configured_engine": "parakeet",
+ "mode": "offline",
+ "model": {
+ "engine": "parakeet",
+ "model_name": "Parakeet",
+ "sample_rate_hz": 16000,
+ "languages": ["en"],
+ "supports_timestamps": true,
+ "supports_language_detection": false,
+ "supports_native_streaming": false
+ }
+ },
+ "realtime": {
+ "configured_engine": "parakeet",
+ "mode": "bounded_buffered_offline",
+ "model": {
+ "engine": "parakeet",
+ "model_name": "Parakeet",
+ "sample_rate_hz": 16000,
+ "languages": ["en"],
+ "supports_timestamps": true,
+ "supports_language_detection": false,
+ "supports_native_streaming": false
+ }
+ }
+}
+```
+
+For the current local ONNX adapters, Parakeet reports English with timestamps and GigaAM reports Russian without timestamps. Neither exposes language detection or native incremental streaming through `transcribe-rs` 0.3.x. Aximo now has a backend extension point for native streaming sessions and automatically switches the realtime WebSocket path to it when the configured backend reports `supports_native_streaming=true`; the bundled Parakeet/GigaAM adapters correctly stay on bounded buffered realtime.
+
+## Short Audio Example
+
+Short transcription currently accepts:
+
+- `audio/wav`
+- `audio/mpeg`
+- `audio/flac`
+- `audio/mp4`
+- `audio/x-m4a`
+- `audio/pcm`
+- `application/octet-stream`
+
+Compressed/container formats are decoded and normalized before inference. `audio/pcm` and `application/octet-stream` are still interpreted as raw `pcm_s16le`, `16 kHz`, mono audio. Short-audio ingest is bounded by HTTP body size, raw PCM byte size, decoded sample count, and decoded duration; limit violations return `413 Payload Too Large`.
+Short-audio inference is also bounded by `short_inference_timeout_ms`; timeout responses use `504 Gateway Timeout` with code `inference_timeout`.
+
+```bash
+curl -X POST http://127.0.0.1:8080/v1/transcriptions \
+ -H 'content-type: audio/wav' \
+ --data-binary @sample.wav
+```
+
+Optional query parameters are accepted for API compatibility and forwarded to the engine request:
+
+- `engine`: must match the configured short-audio engine for this service instance, for example `parakeet`.
+- `language` or `language_hint`: optional backend language hint such as `ru`, `en`, or `auto`; `language_hint` wins when both are supplied.
+- `timestamps`: requests timestamp metadata when the backend supports it. Parakeet can return backend-provided segments; GigaAM may still return an empty `segments` array.
+
+```bash
+curl -X POST 'http://127.0.0.1:8080/v1/transcriptions?engine=parakeet&language=ru×tamps=true' \
+ -H 'content-type: audio/wav' \
+ --data-binary @sample.wav
+```
+
+Example response:
+
+```json
+{
+ "text": "hello world",
+ "segments": [],
+ "detected_language": null,
+ "engine": "parakeet",
+ "duration_ms": 1000,
+ "processing_ms": 37
+}
+```
+
+With the current `transcribe-rs` ONNX adapters used here, `detected_language` is `null` when language detection is not exposed. `segments` is populated only when `timestamps=true` and the selected backend returns real segment metadata. `duration_ms` and `processing_ms` are measured values and vary per request.
+
+Error responses from `POST /v1/transcriptions` are structured JSON:
+
+```json
+{
+ "code": "invalid_audio",
+ "message": "invalid audio payload: pcm payload must be aligned to 16-bit samples"
+}
+```
+
+Unsupported short-audio media types return `415 Unsupported Media Type` with code `unsupported_media_type`. Malformed payloads for supported media types remain `400 invalid_audio`.
+
+## Realtime Example
+
+Realtime uses WebSocket and raw `pcm_s16le`, `16 kHz`, mono binary chunks. If `/v1/capabilities` reports `supports_native_streaming=true`, the WebSocket handler creates a stateful native streaming session and routes chunk/final calls through a bounded native streaming worker with timeout and backpressure handling, so backend calls do not run directly inside the WebSocket loop. Otherwise, Aximo uses bounded buffered realtime.
+For bounded buffered realtime, partial hypotheses are computed from a bounded rolling recent window and use latest-wins coalescing under load, so they favor freshness over a steady partial cadence. The final transcription on `stop` waits for the realtime inference slot and runs over the full bounded session buffer.
+Admission limits and inference limits are configured separately: `max_short_audio_requests` and `max_realtime_sessions` bound accepted work, while `max_short_inferences` and `max_realtime_inferences` bound per-path inference admission. Actual backend execution is additionally protected by a per-engine model gate, shared when offline and realtime reuse the same engine instance.
+Current CPU model execution is safety-first: one loaded model instance has one execution slot. Increase throughput by running more service replicas or, in a future worker-pool design, by loading multiple model replicas.
+Realtime server events are sent through a bounded per-socket queue; clients that stop reading can be disconnected instead of accumulating unbounded memory.
+Realtime chunks must be aligned to `pcm_s16le` sample width; odd-length binary frames return `invalid_audio_chunk`.
+Realtime partial and final inference have separate timeout budgets. A timeout returns an `inference_timeout` event, but the underlying blocking backend call may continue until it returns because Rust cannot safely kill that OS thread. The per-engine model gate stays held until that backend call actually exits, so timed-out calls cannot admit unlimited follow-up backend executions. Native streaming health is tracked separately for stream start, partial chunk handling, and finalization through `realtime_stream:`, `realtime_partial:`, and `realtime_final:`.
+
+```js
+const ws = new WebSocket("ws://127.0.0.1:8080/v1/realtime");
+ws.binaryType = "arraybuffer";
+
+ws.addEventListener("message", (event) => {
+ console.log("server:", event.data);
+});
+
+ws.addEventListener("open", async () => {
+ ws.send(JSON.stringify({ event: "start" }));
+
+ const pcmChunk = new Uint8Array([0, 0, 1, 0, 2, 0, 3, 0]);
+ ws.send(pcmChunk);
+
+ ws.send(JSON.stringify({ event: "stop" }));
+});
+```
+
+Expected server events:
+
+- `session_started`
+- `partial`
+- `final`
+- `error`
+
+`error` events now include machine-readable `code` and human-readable `reason`, for example:
+
+```json
+{
+ "event": "error",
+ "code": "realtime_capacity_exhausted",
+ "reason": "realtime session capacity exhausted"
+}
+```
+
+## API Docs
+
+After the service starts:
+
+- Swagger UI: [http://127.0.0.1:8080/docs/](http://127.0.0.1:8080/docs/)
+- OpenAPI JSON: [http://127.0.0.1:8080/openapi.json](http://127.0.0.1:8080/openapi.json)
+- Metrics: [http://127.0.0.1:8080/metrics](http://127.0.0.1:8080/metrics)
+- Client examples: [client examples](client-examples.md)
+
+The `/docs/` page also includes an `Aximo Recorder` panel that can capture microphone audio in the browser:
+
+- `Short Audio` records locally, converts to WAV, and sends the result to `POST /v1/transcriptions`
+- `Realtime` downsamples to `pcm_s16le 16 kHz mono` and streams binary chunks to `GET /v1/realtime`
+
+For browser microphone access, use `localhost`, `127.0.0.1`, or HTTPS.
+
+
diff --git a/docs/architecture.md b/docs/architecture.md
index 24af49f..780d514 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -124,3 +124,51 @@ sequenceDiagram
`GET /health/live` is process liveness. `GET /health/ready` reflects runtime health and returns `503` after consecutive timeout/runtime/unavailable inference failures cross the configured degradation threshold for any component. Health is tracked per component key, for example `short:parakeet`, `realtime_stream:`, `realtime_partial:parakeet`, and `realtime_final:parakeet`; successful inference clears only the component that succeeded.
`runtime_degraded_policy` controls whether degraded state is only an orchestrator signal or also a request admission policy. `readiness_only` preserves Kubernetes-style behavior where readiness removes the pod from service endpoints but direct callers can still retry. `fail_fast_inference` rejects new short-audio requests or realtime session starts for degraded engine paths with `engine_degraded`, then allows one half-open recovery probe after `runtime_degraded_recovery_cooldown_ms`. If a half-open probe fails with a client-side error before reaching inference, Aximo consumes that probe window and restarts the cooldown while preserving the previous engine failure reason.
+
+## End-to-end overview
+
+```mermaid
+flowchart LR
+ client["Client"]
+ recorder["Swagger microphone recorder"]
+ api["Aximo API
axum HTTP + WebSocket"]
+ short["POST /v1/transcriptions
short audio"]
+ realtime["GET /v1/realtime
WebSocket"]
+ admission["Admission control
body limits, capacity, degraded policy"]
+ audio["Audio preprocessing
MIME parse, decode, normalize, resample"]
+ session["Realtime session manager
bounded PCM buffer, cadence, cleanup"]
+ scheduler["Scheduler semaphores
request/session/inference limits"]
+ gate["Model execution gate
one model instance, one execution slot"]
+ blocking["Blocking inference worker
timeout-bounded client contract"]
+ native["Native streaming worker
only when backend supports it"]
+ engine["Local CPU STT engine
transcribe-rs ONNX adapter"]
+ response["JSON / WebSocket events
text, timings, optional segments"]
+ ops["Operations surface
health, readiness, metrics, capabilities"]
+
+ client --> api
+ recorder --> api
+ api --> short
+ api --> realtime
+
+ short --> admission --> audio --> scheduler --> gate --> blocking --> engine --> response
+ realtime --> admission --> session
+ session --> scheduler
+ scheduler --> gate
+ gate --> blocking
+ gate --> native
+ native --> engine
+ blocking --> engine
+ engine --> response
+
+ api --> ops
+ blocking --> ops
+ native --> ops
+ scheduler --> ops
+```
+
+## Workspace
+
+- `crates/aximo`: HTTP and WebSocket service binary
+- `crates/aximo-core`: scheduler and shared STT domain types
+- `crates/aximo-inference`: `transcribe-rs` adapters for local CPU models
+- `crates/aximo-audio`: audio helpers
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..c6c3694
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,33 @@
+# Configuration
+
+`AXIMO_CONFIG` points to a TOML config file. If it is not set, built-in defaults are used. Individual fields can be overridden with environment variables after the TOML file is loaded, which is useful for Docker and Kubernetes deployments:
+
+```bash
+AXIMO_SERVER_HOST=0.0.0.0
+AXIMO_SERVER_PORT=8080
+AXIMO_SHUTDOWN_GRACE_PERIOD_MS=30000
+AXIMO_MODELS_DIR=/var/lib/aximo/models
+AXIMO_DEFAULT_OFFLINE_ENGINE=parakeet
+AXIMO_DEFAULT_REALTIME_ENGINE=parakeet
+AXIMO_MAX_SHORT_AUDIO_REQUESTS=8
+AXIMO_MAX_SHORT_AUDIO_BYTES=25000000
+AXIMO_MAX_SHORT_RAW_PCM_BYTES=1920000
+AXIMO_MAX_SHORT_AUDIO_DURATION_MS=60000
+AXIMO_MAX_SHORT_DECODED_SAMPLES=5760000
+AXIMO_MAX_REALTIME_SESSIONS=24
+AXIMO_MAX_SHORT_INFERENCES=1
+AXIMO_MAX_REALTIME_INFERENCES=1
+AXIMO_MAX_REALTIME_SESSION_BYTES=1920000
+AXIMO_MAX_REALTIME_SESSION_DURATION_MS=60000
+AXIMO_REALTIME_PARTIAL_MIN_INTERVAL_MS=300
+AXIMO_REALTIME_PARTIAL_MIN_CHUNK_BYTES=9600
+AXIMO_REALTIME_EVENT_CHANNEL_CAPACITY=64
+AXIMO_SHORT_INFERENCE_TIMEOUT_MS=120000
+AXIMO_REALTIME_PARTIAL_TIMEOUT_MS=5000
+AXIMO_REALTIME_FINAL_TIMEOUT_MS=120000
+AXIMO_RUNTIME_DEGRADE_AFTER_CONSECUTIVE_FAILURES=3
+AXIMO_RUNTIME_DEGRADED_POLICY=readiness_only
+AXIMO_RUNTIME_DEGRADED_RECOVERY_COOLDOWN_MS=30000
+```
+
+See [config/aximo.local.toml](../config/aximo.local.toml) for local defaults and [config/aximo.example.toml](../config/aximo.example.toml) for container defaults. Relative model paths are resolved from the working directory.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..bb4881e
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,43 @@
+# Development and CI
+
+See [installation](installation.md) for the pinned Rust toolchain and native prerequisites. No models are needed for the unit/integration suite: it uses fake engines for API tests. Real model inference is a separate smoke check.
+
+## Checks
+
+```bash
+cargo fmt --all --check
+cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
+cargo test --workspace --locked
+```
+
+Optional helpers: `just fmt` formats files, `just lint` runs Clippy, and `just test` runs workspace tests. Python 3 runs the source-setup script tests and docs-link check:
+
+```bash
+python3 -m unittest discover -s scripts/tests -v
+python3 scripts/check-doc-links.py
+```
+
+Coverage uses `cargo install cargo-llvm-cov --locked` and the `llvm-tools-preview` Rust component:
+
+```bash
+cargo llvm-cov --workspace --locked --json --summary-only \
+ --output-path coverage-summary.json --fail-under-lines 88
+```
+
+## What the badges mean
+
+The README badges explicitly track `main`, not a feature branch. A PR passing does not turn a failing `main` badge green until its fix is merged and the relevant main workflow succeeds.
+
+- **CI:** formatting, Clippy, tests, and the 88% line-coverage floor
+- **Security:** `cargo audit --deny warnings`, `cargo deny check`, and CycloneDX SBOM generation
+- **Image Security:** Trivy and Grype scan a freshly built image, failing on fixable high/critical findings
+- **Container:** publishes `ghcr.io/agent-axiom/aximo` on main pushes, semantic version tags, or manual dispatch; model files are excluded, and BuildKit requests SBOM/provenance attestations
+- **Coverage:** the last measured main-branch percentage in `badges/coverage.json`, updated by CI; it is not a live measure of an unmerged PR
+
+Keep security checks strict. Fix vulnerable or yanked dependencies rather than hiding failed jobs or adding advisory exceptions merely to change a badge. Dependency updates should preserve the lockfile and be checked with the commands above.
+
+## Library packaging
+
+`aximo-core`, `aximo-audio`, and `aximo-inference` are publishable library crates. The `aximo` service crate has `publish = false` but can be installed from its checkout.
+
+`just package-libs` checks `aximo-core` and `aximo-audio`. Dry-run publishing `aximo-inference` requires `aximo-core` to already be available in the crates.io index. See [publishing](publishing.md).
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..2d3da6d
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,113 @@
+# Installation and first run
+
+Aximo is a service binary built from a Rust workspace. It is not a microphone CLI: clients send audio to its HTTP/WebSocket API. The `aximo` binary is not published on crates.io, so `cargo install aximo` is not the installation path.
+
+## Prerequisites
+
+For all paths:
+
+- Git, Bash, curl, and tar for the checkout and model downloader
+- Internet access for the initial source, dependency, runtime, and model downloads
+- Disk and RAM for the model plus build artifacts; model loading and inference are CPU-intensive
+
+For a native build:
+
+- [Rust installed through rustup](https://www.rust-lang.org/tools/install); the checkout pins the tested toolchain in `rust-toolchain.toml`
+- A native C/C++ build toolchain and `pkg-config`; on Debian/Ubuntu, install `build-essential pkg-config libssl-dev`
+- On macOS, install the Xcode Command Line Tools; use the repository-pinned Rust toolchain
+
+The ONNX Runtime dependency downloads platform-specific binaries during the build. The supplied Dockerfile uses Debian trixie because some ARM64 runtime binaries require newer glibc/libstdc++ than bookworm. The release image targets Linux AMD64; other native platforms must be verified on the target machine.
+
+For containers, install [Docker with Compose](https://docs.docker.com/compose/install/). A host Rust installation is not required.
+
+## Get the source and model
+
+```bash
+git clone https://github.com/agent-axiom/aximo.git
+cd aximo
+./scripts/fetch-models.sh
+```
+
+The script downloads the default Parakeet v3 int8 ONNX bundle into `var/models/parakeet-tdt-0.6b-v3-int8/`. Models are runtime data and are not committed to Git. `just setup-models` is an optional alias if you have `just` installed.
+
+Compatible model sources:
+
+- [Parakeet v3 int8 archive](https://blob.handy.computer/parakeet-v3-int8.tar.gz), used by the script
+- [Parakeet ONNX files](https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/tree/main)
+- [GigaAM v3 ONNX files](https://huggingface.co/istupakov/gigaam-v3-onnx/tree/main)
+
+Check [model licenses](model-licenses.md) before redistribution. GigaAM is optional; it is not downloaded by the default script. To select it, supply its model directory and set both `AXIMO_DEFAULT_OFFLINE_ENGINE=gigaam` and `AXIMO_DEFAULT_REALTIME_ENGINE=gigaam`. The request's `engine` query parameter does not hot-swap models.
+
+## Run with Docker Compose
+
+From the checkout after downloading the model:
+
+```bash
+docker compose up --build
+```
+
+Compose mounts `var/models` read-only, loads `config/aximo.example.toml`, and publishes port 8080 on the host's loopback interface. It builds the image from this checkout. To serve other machines, deliberately change the port binding and follow [deployment security](deployment-security.md).
+
+## Build and run natively
+
+```bash
+cargo build --release --locked -p aximo
+AXIMO_CONFIG=config/aximo.local.toml ./target/release/aximo
+```
+
+`config/aximo.local.toml` binds `127.0.0.1:8080` and uses `./var/models`. Run it from the repository root so relative paths resolve correctly. The service loads the selected models before it starts accepting requests.
+
+For development, the equivalent command is:
+
+```bash
+AXIMO_CONFIG=config/aximo.local.toml cargo run --locked -p aximo
+```
+
+## Install the binary from the checkout
+
+```bash
+cargo install --locked --path crates/aximo
+AXIMO_CONFIG="$PWD/config/aximo.local.toml" \
+ AXIMO_MODELS_DIR="$PWD/var/models" aximo
+```
+
+`cargo install` installs the binary under Cargo's bin directory, normally `~/.cargo/bin`. It does not install models or configuration. Use absolute paths, as above, when running outside the checkout. Ensure Cargo's bin directory is on your `PATH`.
+
+There is no dedicated `--help`/`--version` command yet: the current binary starts the server, so use the API checks below to verify it.
+
+## Verify the running service
+
+In another terminal:
+
+```bash
+curl --fail-with-body http://127.0.0.1:8080/health/ready
+curl --fail-with-body http://127.0.0.1:8080/v1/capabilities
+```
+
+Open [the recorder and API docs](http://127.0.0.1:8080/docs/). Browser microphone access requires localhost/loopback or HTTPS and your explicit permission.
+
+Send an existing WAV file:
+
+```bash
+curl --fail-with-body --max-time 130 \
+ http://127.0.0.1:8080/v1/transcriptions \
+ -H 'Content-Type: audio/wav' --data-binary @sample.wav
+```
+
+The response contains recognized `text`. Audio is the raw request body, not a multipart form. Default short-audio limits include a 60-second duration cap; see [configuration](configuration.md). Run the repeatable API smoke check with Python 3:
+
+```bash
+python3 scripts/smoke-test.py
+```
+
+The smoke check uses generated silence, checks service endpoints and real inference, and does not access a microphone. It verifies service wiring, not recognition quality. For speech accuracy, test representative Russian/English recordings on the selected model and hardware.
+
+## Troubleshooting
+
+- **Model missing or invalid:** check all required model files and the working directory. To retry a damaged download, run `AXIMO_FORCE=1 ./scripts/fetch-models.sh`.
+- **Linker/OpenSSL build errors:** install the native build prerequisites above. Linux containers provide a reproducible alternative.
+- **Port already used:** choose another `AXIMO_SERVER_PORT` and adjust your client URL.
+- **HTTP 413:** the recording exceeds configured audio limits. Shorten it or intentionally raise the relevant limits.
+- **HTTP 429 or 504:** the instance is saturated or inference exceeded its timeout. Stop competing requests and see [operations](operations.md).
+
+Further reading: [API reference](api-reference.md), [configuration](configuration.md), [terminal voice input](terminal-voice.md), and [development](development.md).
diff --git a/docs/operations.md b/docs/operations.md
new file mode 100644
index 0000000..6fb73d9
--- /dev/null
+++ b/docs/operations.md
@@ -0,0 +1,46 @@
+# Operations and troubleshooting
+
+## Metrics and health
+
+`GET /metrics` exposes lightweight Prometheus text metrics for operational visibility:
+
+- `aximo_http_requests_total{status,code}`
+- `aximo_errors_total{code}`
+- `aximo_audio_body_bytes_total`
+- `aximo_audio_decode_seconds_bucket/sum/count`
+- `aximo_audio_duration_seconds_bucket/sum/count`
+- `aximo_inference_wait_seconds_bucket/sum/count{kind}`
+- `aximo_model_execution_wait_seconds_bucket/sum/count{kind}`
+- `aximo_inference_seconds_bucket/sum/count{kind}`
+- `aximo_rtf_bucket/sum/count{kind}`
+- `aximo_inference_timeouts_total{kind}`
+- `aximo_blocking_tasks_active`
+- `aximo_model_executions_active`
+- `aximo_runtime_degraded`
+- `aximo_runtime_consecutive_failures`
+- `aximo_runtime_component_degraded{component}`
+- `aximo_runtime_component_consecutive_failures{component}`
+- `aximo_ws_sessions_active`
+- `aximo_ws_queue_overflows_total`
+- `aximo_realtime_partial_coalesced_total`
+- `aximo_realtime_stale_partial_skips_total`
+- `aximo_model_execution_wait_timeouts_total`
+
+Latency and RTF metrics are emitted as Prometheus histograms, so dashboards can use `histogram_quantile()` for p95/p99 without depending only on averages.
+
+`/health/live` is process liveness. `/health/ready` reports aggregate readiness and per-component details such as `short:parakeet`, `realtime_partial:parakeet`, and `realtime_final:parakeet`. It returns `503` with a JSON `degraded` status after consecutive timeout/runtime/unavailable inference failures for any component reach `runtime_degrade_after_consecutive_failures`. A successful inference clears only its own component state. `runtime_degraded_policy = "readiness_only"` only signals orchestrators through readiness; `runtime_degraded_policy = "fail_fast_inference"` additionally rejects new inference work for degraded components with `engine_degraded`, then allows one half-open recovery probe after `runtime_degraded_recovery_cooldown_ms`. Client-side errors that stop a half-open probe before inference consume the probe window and restart the cooldown without changing the prior engine failure reason.
+
+On SIGINT or SIGTERM, Aximo notifies active websocket handlers, sends close frames, stops accepting new connections through axum graceful shutdown, and waits up to `shutdown_grace_period_ms`.
+
+## Troubleshooting
+
+If container logs include `onnxruntime cpuid_info warning: Unknown CPU vendor`, this is typically an ONNX Runtime CPU detection warning on ARM or virtualized environments, not a model-load failure. The container now sets `ORT_LOG=error` to reduce that noise in normal runs.
+
+## Known limits
+
+- Realtime uses a native streaming session only when `/v1/capabilities` reports `supports_native_streaming=true`; Parakeet and GigaAM currently report `false`, so they intentionally use bounded buffered realtime.
+- Native streaming currently uses one native worker thread per active native streaming session. This keeps backend calls out of the WebSocket loop, but high native-streaming session counts must be benchmarked before raising `max_realtime_sessions`.
+- `segments` is backend-dependent and only returned when `timestamps=true`; `detected_language` stays `null` while `/v1/capabilities` reports `supports_language_detection=false`.
+- Container decode now avoids an extra input-buffer copy from axum `Bytes`, but decoded samples are still materialized in memory before normalization.
+- Audio resampling now uses a bounded windowed-sinc path, but production WER/CER work should still validate preprocessing quality against real audio.
+- Remaining product work is backend-driven: plug in a backend that exposes language detection when that capability is required.
diff --git a/docs/terminal-voice.md b/docs/terminal-voice.md
new file mode 100644
index 0000000..687331d
--- /dev/null
+++ b/docs/terminal-voice.md
@@ -0,0 +1,117 @@
+# Local voice input for terminals and Claude Code
+
+**Status: integration assessment, checked 2026-10-07.** Aximo supplies local
+transcription over HTTP and WebSocket. This repository does not yet ship a
+terminal microphone client or a Claude Code plugin. Start with an existing WAV
+and manual paste; the in-session integration below is a proposed next step.
+
+## What works today
+
+- Run Aximo on loopback using the [installation guide](installation.md), then
+ send a whole audio file to `POST /v1/transcriptions`. The request body is raw
+ audio bytes, not multipart form data. Read the final JSON `text` field.
+- Check `GET /v1/capabilities` for the installed model's actual language support.
+ The bundled adapters report English for Parakeet and Russian for GigaAM.
+ Set `default_offline_engine = "gigaam"` and restart for Russian transcription;
+ adding `?engine=gigaam` cannot switch a Parakeet-configured instance.
+- Default short-audio limits include 60 seconds of audio and a 120-second
+ inference timeout. Keep recordings shorter than the configured limit. See the
+ [API reference](api-reference.md) and [configuration](configuration.md).
+- WebSocket partials are available, but the bundled adapters use bounded
+ buffered offline decoding, not a true incremental decoder. For a first
+ dictation client, one final HTTP result is simpler and avoids unstable partial
+ text in a prompt. See the [realtime protocol](realtime-protocol.md).
+
+Claude Code already has `/voice`, including Russian support, but it streams
+audio to Anthropic for transcription. It requires Claude.ai authentication and
+a local microphone; SSH and cloud sessions are unsupported. The official voice
+documentation does not describe a custom STT endpoint setting, so Aximo is not a
+documented drop-in backend for `/voice`. Hold mode normally waits for Enter;
+tap mode can submit automatically. See [Claude Code voice
+dictation](https://code.claude.com/docs/en/voice-dictation).
+
+## Try an existing recording
+
+Prerequisites: Aximo is already running on `127.0.0.1:8080`, `curl` and `jq` are
+installed, and `sample.wav` is an existing recording within the configured
+limits. Run this in Bash. It uploads that file only when you run it; it does not
+start a microphone or invoke Claude.
+
+```bash
+if transcript=$(
+ set -o pipefail
+ curl --fail --silent --show-error --noproxy '*' \
+ --connect-timeout 3 --max-time 130 \
+ -H 'Content-Type: audio/wav' \
+ --data-binary @sample.wav \
+ http://127.0.0.1:8080/v1/transcriptions |
+ jq -er '
+ .text | select(type == "string") | select(test("\\S")) |
+ select(test("[\u0000-\u0008\u000b-\u001f\u007f-\u009f]") | not)
+ '
+); then
+ printf '%s\n' "$transcript"
+else
+ unset transcript
+ printf '%s\n' 'Transcription failed or returned empty/unsafe text.' >&2
+fi
+```
+
+Review the displayed text, copy it into Claude Code's prompt, edit it, and press
+Enter yourself. Do not paste unreviewed speech into an executable shell prompt.
+After reviewing, you can alternatively start an interactive session with
+`claude "$transcript"`; this submits an initial prompt immediately, so run it
+only intentionally. Prefer manual paste if the text begins with `-`, which may
+be parsed as a CLI option. Never use `eval`, `sh -c`, or unquoted transcript
+expansion. Command-line arguments can also be visible to other local processes.
+See the [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference).
+
+## Proposed in-session `/aximo-dictate` mod
+
+The current official [mods
+overview](https://code.claude.com/docs/en/plugins/mods/overview) documents custom
+commands in the terminal on Claude Code v2.1.287 or later. A mod is a plausible
+integration surface, but this design has not been implemented or tested here.
+
+1. Register `/aximo-dictate` with explicit Start, Stop, and Cancel controls and a
+ visible recording indicator. Request OS microphone permission only after the
+ user starts recording; allow only one recording per session.
+2. Launch a trusted, platform-specific recorder helper through `$.process.run`
+ or `$.process.spawn`, passing an argument array rather than shell text. Have
+ the helper capture bounded mono WAV audio, POST its bytes to the fixed
+ loopback endpoint, and return structured JSON. Use finite capture and HTTP
+ timeouts; `process.run` defaults to 30 seconds, so configure its supported
+ timeout for the full recording/transcription budget. See the [mods
+ API](https://code.claude.com/docs/en/plugins/mods/api).
+3. Validate the helper exit status, HTTP status, JSON shape, and a nonempty,
+ bounded final `text` string without terminal control sequences. Insert it as
+ editable draft text through `$.prompt.fill`, preserving existing user text.
+ Do not overwrite a prompt edited while transcription was pending.
+4. Let the user review and press Enter. Never call `prompt.submit`, generate
+ synthetic keystrokes, or execute the transcript as a command. Cancel must
+ stop capture, discard late results, and leave the prompt unchanged.
+
+The [mods reference](https://code.claude.com/docs/en/plugins/mods/reference)
+lists `prompt.read`/`prompt.fill` and process APIs; its audio namespace offers
+`play` and `speak`, with no documented microphone recording method. That is why
+the proposed design needs a recorder helper. Pin a tested Claude Code version
+and use the generated `.claude-plugin/types/` declarations as the authority for
+exact signatures. Validate the plugin and test cancel, timeout, microphone
+denial, malformed/empty results, and concurrent prompt edits before use. See
+[Create a mod](https://code.claude.com/docs/en/plugins/mods/create).
+
+## Privacy and operating boundaries
+
+Keep the service and helper on the microphone's machine and bind to loopback;
+do not expose an unauthenticated transcription port to the network. A remote
+shell has no automatic access to your laptop microphone. See
+[deployment security](deployment-security.md) for non-local deployment.
+
+Local transcription keeps audio out of a cloud STT service when this localhost
+flow is used. It does **not** mean audio never touches disk: the current
+[runtime adapter](../crates/aximo-inference/src/runtime.rs) materializes a
+temporary WAV, and a recorder may also create files. Use restrictive temporary
+file permissions and cleanup on success, cancellation, and errors; avoid audio
+or transcript logging. Submitted Claude prompts still go to the configured
+model provider. Install only reviewed mods and helpers: mods run with your user
+permissions, not inside Claude Code's Bash sandbox.
diff --git a/scripts/check-doc-links.py b/scripts/check-doc-links.py
new file mode 100644
index 0000000..566f893
--- /dev/null
+++ b/scripts/check-doc-links.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Check relative file links in repository Markdown (no network required)."""
+import re
+from pathlib import Path
+from urllib.parse import unquote, urlsplit
+
+root = Path(__file__).resolve().parents[1]
+errors = []
+for document in [root / "README.md", root / "SECURITY.md", *sorted((root / "docs").rglob("*.md"))]:
+ # Exclude fenced examples so sample Markdown is not treated as navigation.
+ text = re.sub(r"```.*?```", "", document.read_text(), flags=re.S)
+ for target in re.findall(r"!?\[[^\]]*\]\(([^\s)]+)(?:\s+[^)]*)?\)", text):
+ link = urlsplit(target)
+ if link.scheme or link.netloc or not link.path:
+ continue
+ destination = document.parent / unquote(link.path)
+ if not destination.exists():
+ errors.append(f"{document.relative_to(root)}: missing {target}")
+if errors:
+ raise SystemExit("\n".join(errors))
+print("All relative Markdown file links resolve.")
diff --git a/scripts/fetch-models.sh b/scripts/fetch-models.sh
index 82fb6c6..10a9964 100755
--- a/scripts/fetch-models.sh
+++ b/scripts/fetch-models.sh
@@ -40,7 +40,29 @@ PARAKEET_URL="${AXIMO_PARAKEET_URL:-https://blob.handy.computer/parakeet-v3-int8
TARGET_DIR="${MODELS_DIR}/parakeet-tdt-0.6b-v3-int8"
FORCE_DOWNLOAD="${AXIMO_FORCE:-0}"
+required_files=(
+ encoder-model.int8.onnx
+ decoder_joint-model.int8.onnx
+ nemo128.onnx
+ vocab.txt
+)
+
+validate_model() {
+ local directory="$1"
+ local required
+ for required in "${required_files[@]}"; do
+ if [[ ! -f "${directory}/${required}" || ! -s "${directory}/${required}" ]]; then
+ echo "Missing or empty required model file: ${directory}/${required}" >&2
+ return 1
+ fi
+ done
+}
+
if [[ -d "${TARGET_DIR}" && "${FORCE_DOWNLOAD}" != "1" ]]; then
+ if ! validate_model "${TARGET_DIR}"; then
+ echo "Run AXIMO_FORCE=1 $0 to replace the incomplete model." >&2
+ exit 1
+ fi
echo "Parakeet model already present at ${TARGET_DIR}"
exit 0
fi
@@ -58,7 +80,7 @@ trap cleanup EXIT
mkdir -p "${MODELS_DIR}" "${extract_dir}"
echo "Downloading Parakeet model from ${PARAKEET_URL}"
-curl -fL "${PARAKEET_URL}" -o "${archive_path}"
+curl --retry 3 --connect-timeout 15 -fL "${PARAKEET_URL}" -o "${archive_path}"
echo "Extracting model archive"
tar -xzf "${archive_path}" -C "${extract_dir}"
@@ -80,20 +102,10 @@ if [[ -z "${source_dir}" ]]; then
exit 1
fi
+# Validate the download before touching a previously installed model.
+validate_model "${source_dir}"
rm -rf "${TARGET_DIR}"
mkdir -p "${TARGET_DIR}"
cp -R "${source_dir}/." "${TARGET_DIR}/"
-for required in \
- encoder-model.int8.onnx \
- decoder_joint-model.int8.onnx \
- nemo128.onnx \
- vocab.txt
-do
- if [[ ! -f "${TARGET_DIR}/${required}" ]]; then
- echo "Missing required model file: ${TARGET_DIR}/${required}" >&2
- exit 1
- fi
-done
-
echo "Model ready at ${TARGET_DIR}"
diff --git a/scripts/smoke-test.py b/scripts/smoke-test.py
new file mode 100644
index 0000000..4a24754
--- /dev/null
+++ b/scripts/smoke-test.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""Check a running Aximo instance using generated silence, without a microphone."""
+import argparse
+import io
+import json
+import urllib.error
+import urllib.request
+import wave
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--base-url", default="http://127.0.0.1:8080")
+ args = parser.parse_args()
+ base = args.base_url.rstrip("/")
+
+ def request(path, data=None, media_type=None, expected=200):
+ headers = {"Content-Type": media_type} if media_type else {}
+ req = urllib.request.Request(base + path, data=data, headers=headers)
+ try:
+ response = urllib.request.urlopen(req, timeout=130)
+ except urllib.error.HTTPError as error:
+ response = error
+ with response:
+ body = response.read()
+ if response.status != expected:
+ raise RuntimeError(f"{path}: HTTP {response.status}, expected {expected}: {body[:500]!r}")
+ print(f"PASS {path}: HTTP {response.status}")
+ return body
+
+ request("/health/live")
+ json.loads(request("/health/ready"))
+ capabilities = json.loads(request("/v1/capabilities"))
+ assert "offline" in capabilities and "realtime" in capabilities, capabilities
+ schema = json.loads(request("/openapi.json"))
+ assert "/v1/transcriptions" in schema["paths"], schema.keys()
+ assert b"aximo_http_requests_total" in request("/metrics")
+ assert b"html" in request("/docs/").lower()
+
+ audio = io.BytesIO()
+ with wave.open(audio, "wb") as wav:
+ wav.setnchannels(1)
+ wav.setsampwidth(2)
+ wav.setframerate(16000)
+ wav.writeframes(b"\x00\x00" * 16000)
+ result = json.loads(request("/v1/transcriptions", audio.getvalue(), "audio/wav"))
+ assert isinstance(result["text"], str), result
+ assert result["engine"] == capabilities["offline"]["configured_engine"], result
+ assert result["duration_ms"] == 1000, result
+ assert result["processing_ms"] >= 0, result
+
+ error = json.loads(request("/v1/transcriptions", b"not audio", "text/plain", 415))
+ assert error["code"] == "unsupported_media_type", error
+ error = json.loads(request("/v1/transcriptions", b"\x00", "audio/pcm", 400))
+ assert error["code"] == "invalid_audio", error
+ print("Aximo smoke check passed. Silence verifies wiring, not speech accuracy.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/tests/test_fetch_models.py b/scripts/tests/test_fetch_models.py
new file mode 100644
index 0000000..b3e6006
--- /dev/null
+++ b/scripts/tests/test_fetch_models.py
@@ -0,0 +1,99 @@
+"""Exercise model setup without network access or large model downloads."""
+import os
+from pathlib import Path
+import subprocess
+import tarfile
+import tempfile
+import unittest
+
+SCRIPT = Path(__file__).resolve().parents[1] / "fetch-models.sh"
+FILES = ("encoder-model.int8.onnx", "decoder_joint-model.int8.onnx", "nemo128.onnx", "vocab.txt")
+
+
+class FetchModelsTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ self.models = self.root / "models"
+ self.target = self.models / "parakeet-tdt-0.6b-v3-int8"
+ self.bin = self.root / "bin"
+ self.bin.mkdir()
+ curl = self.bin / "curl"
+ curl.write_text('#!/usr/bin/env bash\nset -eu\nwhile [[ "$1" != "-o" ]]; do shift; done\ncp "$TEST_ARCHIVE" "$2"\n')
+ curl.chmod(0o755)
+ self.env = {**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"]}
+ self.env.pop("AXIMO_FORCE", None)
+ self.env.pop("AXIMO_MODELS_DIR", None)
+
+ def archive(self, missing=None, dirname="parakeet-tdt-0.6b-v3-int8"):
+ bundle = self.root / "bundle" / dirname
+ bundle.mkdir(parents=True)
+ for name in FILES:
+ if name != missing:
+ (bundle / name).write_bytes(b"fixture")
+ archive = self.root / "fixture.tar.gz"
+ with tarfile.open(archive, "w:gz") as tar:
+ tar.add(bundle, arcname=dirname)
+ self.env["TEST_ARCHIVE"] = str(archive)
+
+ def run_script(self):
+ return subprocess.run(["bash", str(SCRIPT), str(self.models)], env=self.env, text=True, capture_output=True)
+
+ def test_valid_bundle_and_repeat(self):
+ self.archive()
+ result = self.run_script()
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(all((self.target / name).is_file() for name in FILES))
+ del self.env["TEST_ARCHIVE"] # Repeat must not download again.
+ self.assertEqual(self.run_script().returncode, 0)
+
+ def test_alternative_archive_directory(self):
+ self.archive(dirname="parakeet-v3")
+ self.assertEqual(self.run_script().returncode, 0)
+
+ def test_incomplete_existing_directory_is_not_success(self):
+ self.target.mkdir(parents=True)
+ result = self.run_script()
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("AXIMO_FORCE=1", result.stderr)
+
+ def test_empty_existing_file_is_not_success(self):
+ self.target.mkdir(parents=True)
+ for name in FILES:
+ (self.target / name).write_bytes(b"")
+ self.assertNotEqual(self.run_script().returncode, 0)
+
+ def test_invalid_download_preserves_previous_model(self):
+ self.target.mkdir(parents=True)
+ for name in FILES:
+ (self.target / name).write_bytes(b"previous")
+ self.archive(missing="nemo128.onnx")
+ self.env["AXIMO_FORCE"] = "1"
+ self.assertNotEqual(self.run_script().returncode, 0)
+ self.assertEqual((self.target / "vocab.txt").read_bytes(), b"previous")
+
+ def test_directory_instead_of_model_file_preserves_previous_model(self):
+ self.target.mkdir(parents=True)
+ for name in FILES:
+ (self.target / name).write_bytes(b"previous")
+ self.archive(missing="encoder-model.int8.onnx")
+ bundle = self.root / "bundle" / "parakeet-tdt-0.6b-v3-int8"
+ (bundle / "encoder-model.int8.onnx").mkdir()
+ with tarfile.open(self.env["TEST_ARCHIVE"], "w:gz") as tar:
+ tar.add(bundle, arcname=bundle.name)
+ self.env["AXIMO_FORCE"] = "1"
+ self.assertNotEqual(self.run_script().returncode, 0)
+ self.assertEqual((self.target / "vocab.txt").read_bytes(), b"previous")
+
+ def test_forced_valid_download_replaces_previous_model(self):
+ self.target.mkdir(parents=True)
+ (self.target / "vocab.txt").write_bytes(b"previous")
+ self.archive()
+ self.env["AXIMO_FORCE"] = "1"
+ self.assertEqual(self.run_script().returncode, 0)
+ self.assertEqual((self.target / "vocab.txt").read_bytes(), b"fixture")
+
+
+if __name__ == "__main__":
+ unittest.main()