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 -[![CI](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml/badge.svg)](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml) -[![Security](https://github.com/agent-axiom/aximo/actions/workflows/security.yml/badge.svg)](https://github.com/agent-axiom/aximo/actions/workflows/security.yml) -[![Image Security](https://github.com/agent-axiom/aximo/actions/workflows/image-security.yml/badge.svg)](https://github.com/agent-axiom/aximo/actions/workflows/image-security.yml) -[![Container](https://github.com/agent-axiom/aximo/actions/workflows/container.yml/badge.svg)](https://github.com/agent-axiom/aximo/actions/workflows/container.yml) +[![CI](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml) +[![Security](https://github.com/agent-axiom/aximo/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/agent-axiom/aximo/actions/workflows/security.yml) +[![Image Security](https://github.com/agent-axiom/aximo/actions/workflows/image-security.yml/badge.svg?branch=main)](https://github.com/agent-axiom/aximo/actions/workflows/image-security.yml) +[![Container](https://github.com/agent-axiom/aximo/actions/workflows/container.yml/badge.svg?branch=main)](https://github.com/agent-axiom/aximo/actions/workflows/container.yml) [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/agent-axiom/aximo/main/badges/coverage.json)](https://github.com/agent-axiom/aximo/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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. - -![Aximo Swagger recorder](docs/assets/swagger-recorder.png) - -## 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. + +![Aximo Swagger recorder](assets/swagger-recorder.png) 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()