This section is structured for fast, unambiguous ingestion by language models. Maximum information density, minimum narrative prose. If you are a human, jump to π₯ Human Documentationβ .
| Field | Value |
|---|---|
| Name | Ruistper |
| Binary | ruistper |
| Language | Rust, edition 2021, MSRV 1.78 |
| Async runtime | tokio multi-thread |
| Role | RabbitMQ worker β consumes audio jobs β transcribes via whisper.cpp (GGML) β publishes result back to RabbitMQ |
| Origin | Rust rewrite of a Go + Python stack; messaging contracts are protocol-identical to the original |
| Crate | Version | Purpose |
|---|---|---|
tokio | 1 | Async multi-thread runtime (rt-multi-thread, macros, sync, fs, signal, time) |
lapin | 2.3 | AMQP 0-9-1 client |
deadpool-lapin | 0.12 | Connection pool over lapin |
futures-util | 0.3 | StreamExt on consumer delivery stream |
serde + serde_json | 1 | JSON serialisation / deserialisation |
symphonia | 0.5 | Audio decode: MP3, WAV, FLAC, OGG/Vorbis, AAC, M4A |
ogg | 0.8 | OGG container demux for Opus streams (bypasses Symphonia) |
opus | 0.3 | OGG/Opus decode via FFI to system libopus |
rubato | 0.15 | SincFixedIn resampler β mono f32 @ 16 000 Hz |
hound | 3 | WAV writing for optional intermediate files |
whisper-rs | 0.13 | Unsafe bindings to whisper.cpp GGML C library |
tracing | 0.1 | Structured, async-aware logging |
tracing-subscriber | 0.3 | RUST_LOG env-filter subscriber |
src/main.rs
#[tokio::main]; tracing_subscriber init; app::run()
src/app.rs
async fn run() β Result<(), AppError>
Startup sequence: Config β tmp_dir β Metrics β shutdown pair
β RabbitMQ pool (workers+2) β WhisperModel (blocking, once)
β WhisperEngine β RabbitProducer β RabbitConsumer β WorkerPool
src/config.rs
struct Config { rabbitmq_url, workers_count, whisper_model,
whisper_device, models_dir, max_file_size_mb,
max_audio_duration_sec, audio_sample_rate,
tmp_dir, api_host, api_port }
Config::load() β reads env vars, validates
Config::model_path() β PathBuf ({models_dir}/ggml-{whisper_model}.bin)
Config::max_file_size_bytes() β u64
Config::log_summary()
src/retry.rs
const MAX_RETRIES: i32 = 2 // 3 total attempts
enum RetryDecision { Retry, GiveUp }
RetryPolicy::decide(retry_count) β RetryDecision
src/metrics.rs
struct Metrics {
received: AtomicU64,
succeeded: AtomicU64,
failed: AtomicU64,
retried: AtomicU64,
in_flight: AtomicI64,
}
Arc<Metrics> shared across workers
src/shutdown.rs
fn new_pair() β (ShutdownHandle, ShutdownSignal)
fn wait_for_os_signal() // SIGINT + SIGTERM
src/model/request.rs
struct TranscriptionRequest {
attachment_id: i64,
audio_file_path: String,
language: Option<String>,
import_batch_id: Option<i64>,
retry_count: i32,
}
src/model/result.rs
struct TranscriptionResult {
attachment_id: i64,
texto: String,
duration: f64,
model: String,
success: bool,
import_batch_id: Option<i64>,
error_message: Option<String>, // skip_serializing_if = None
}
TranscriptionResult::success(attachment_id, texto, duration, model, import_batch_id) β Self
TranscriptionResult::failure(attachment_id, model, import_batch_id, error_message) β Self
src/audio/decoder.rs
fn decode(path: &Path) β Result<DecodedAudio, DecodeError>
fn is_ogg_opus(path) β bool // reads first 128 bytes for "OpusHead" magic
OGG/Opus path β decode_ogg_opus() via libopus
All other formats β Symphonia probe/decode loop
src/audio/resampler.rs
fn to_mono_16k(decoded: &DecodedAudio) β Vec<f32> // rubato SincFixedIn
src/audio/pipeline.rs
struct AudioLimits { max_file_size_bytes: u64, max_duration_secs: f64 }
struct ProcessedAudio { samples: Vec<f32>, duration_secs: f64 }
fn process(path: &Path, limits: AudioLimits) β Result<ProcessedAudio, AudioError>
Validation order: exists+size β extension β decode β duration β resample
enum AudioError {
FileNotFound(String), // Deterministic
UnsupportedFormat(String), // Deterministic
FileTooLarge { size_bytes, limit_bytes }, // Deterministic
DurationExceeded { duration_secs, limit_secs }, // Deterministic
Decode(DecodeError), // Transient
Resample(ResampleError), // Transient
}
src/whisper/model.rs
struct WhisperModel { context: WhisperContext, name: String }
unsafe impl Send + Sync // required; context is not Send by default
WhisperModel::load(path: &Path, name: String) β Result<Self, ModelError>
src/whisper/engine.rs
#[derive(Clone)]
struct WhisperEngine { model: Arc<WhisperModel> }
WhisperEngine::new(model: Arc<WhisperModel>) β Self
WhisperEngine::model_name() β &str
WhisperEngine::transcribe(&self, samples: &[f32], language: Option<&str>)
β Result<TranscriptionOutput, EngineError>
Params: BeamSearch { beam_size: 5 }, no_speech_thold = 0.6
WhisperState: allocated fresh per call (not reused across calls)
struct TranscriptionOutput { text: String }
enum EngineError { StateCreation(String), Inference(String), SegmentRead(String) }
src/messaging/rabbit.rs
pub type Pool = deadpool_lapin::Pool
fn build_pool(url: &str, max_size: usize) β Result<Pool, RabbitError>
// Exchange / queue / routing-key string constants:
WHISPER_EXCHANGE = "whisper_exchange"
WHISPER_QUEUE = "whisper_transcriptions"
WHISPER_ROUTING_KEY = "transcription.request"
RESULTS_EXCHANGE = "whisper_results_exchange"
RESULTS_QUEUE = "whisper_results"
RESULTS_ROUTING_KEY = "transcription.result"
RETRY_EXCHANGE = "whisper_retry_exchange"
RETRY_QUEUE = "whisper_retry_queue"
RETRY_ROUTING_KEY = "transcription.retry"
RETRY_TTL_MS = 5000
src/messaging/consumer.rs
struct Job { request: TranscriptionRequest, delivery: Delivery }
struct RabbitConsumer
RabbitConsumer::new(pool: &Pool, prefetch: u16) β Result<Self, ConsumerError>
RabbitConsumer::into_receiver(self) β Result<mpsc::Receiver<Job>, ConsumerError>
// Invalid JSON β NACK(requeue=false); valid β forwarded to channel
src/messaging/producer.rs
#[derive(Clone)]
struct RabbitProducer { pool: Pool, model_name: String }
RabbitProducer::new(pool: &Pool, model_name: String) β Result<Self, ProducerError>
RabbitProducer::publish_success(result: TranscriptionResult) β Result<(), ProducerError>
RabbitProducer::publish_error(result: TranscriptionResult) β Result<(), ProducerError>
RabbitProducer::publish_retry(request: TranscriptionRequest) β Result<(), ProducerError>
// publish_retry: increments retry_count; sets x-retry-count AMQP header
src/worker/task.rs
async fn process(worker_id: usize, job: Job, engine: WhisperEngine,
producer: RabbitProducer, limits: AudioLimits,
metrics: Arc<Metrics>)
enum TaskError { Deterministic(String), Transient(String) }
// Deterministic β publish_error + ACK (no retry)
// Transient β handle_failure (retry via publish_retry, or final publish_error + ACK)
// JoinError (panic in spawn_blocking) β treated as Transient
// Any publish failure β NACK(requeue=true)
// Blocking work dispatched via tokio::task::spawn_blocking:
// audio::pipeline::process β WhisperEngine::transcribe
src/worker/pool.rs
struct WorkerPool { workers_count: usize, engine: WhisperEngine,
producer: RabbitProducer, limits: AudioLimits,
metrics: Arc<Metrics> }
WorkerPool::run(self, jobs_rx: mpsc::Receiver<Job>, shutdown_signal: ShutdownSignal)
// N worker tasks share Arc<Mutex<Receiver>>
// Channel capacity = workers_count Γ 2
INPUT
exchange "whisper_exchange" type=direct, durable
queue "whisper_transcriptions" durable
routing_key "transcription.request"
OUTPUT
exchange "whisper_results_exchange" type=direct, durable
queue "whisper_results" durable
routing_key "transcription.result"
RETRY (Dead-Letter Exchange pattern)
exchange "whisper_retry_exchange" type=direct, durable
queue "whisper_retry_queue" durable, x-message-ttl=5000ms
routing_key "transcription.retry"
DLX on TTL expiry β "whisper_exchange" (message re-enters main queue)
MAX_RETRIES = 2 β 3 total processing attempts
Field names are domain-specific to the original application. Adapt for other use cases β the service only cares that the JSON is valid against the structs in
src/model/.
Request (inbound)
{
"attachment_id": 123,
"audio_file_path": "/mnt/audio/voice_001.ogg",
"language": "es",
"import_batch_id": 42,
"retry_count": 0
}
| Field | Rust type | Required | Notes |
|---|---|---|---|
attachment_id | i64 | β | Asset identifier, echoed in result |
audio_file_path | String | β | Absolute path inside the container |
language | Option<String> | β | ISO 639-1 ("es", "en", β¦); null = auto-detect |
import_batch_id | Option<i64> | β | Passed through unchanged |
retry_count | i32 | β | Managed by the service; send 0 or omit |
Result (outbound β success)
{
"attachment_id": 123,
"texto": "Hola, ΒΏcΓ³mo estΓ‘s?",
"duration": 4.32,
"model": "base-q5_1",
"success": true,
"import_batch_id": 42
}
Result (outbound β failure)
{
"attachment_id": 123,
"texto": "",
"duration": 0.0,
"model": "base-q5_1",
"success": false,
"import_batch_id": 42,
"error_message": "file not found: /mnt/audio/voice_001.ogg"
}
| Field | Rust type | Notes |
|---|---|---|
attachment_id | i64 | Echoed from request |
texto | String | Full transcript; "" on failure |
duration | f64 | Audio duration in seconds; 0.0 on failure |
model | String | Model identifier used |
success | bool | |
import_batch_id | Option<i64> | Echoed from request |
error_message | Option<String> | Serialized only when success = false (skip_serializing_if) |
AudioError variant | Class | Retry? |
|---|---|---|
FileNotFound | Deterministic | β |
UnsupportedFormat | Deterministic | β |
FileTooLarge | Deterministic | β |
DurationExceeded | Deterministic | β |
Decode | Transient | β |
Resample | Transient | β |
EngineError (all variants) | Transient | β |
JoinError (spawn_blocking panic) | Transient | β |
AUDIO_SAMPLE_RATE must equal 16000 β whisper.cpp hard requirement; changing this breaks inference.WhisperModel is loaded exactly once at startup (expensive blocking call). Arc<WhisperModel> is cloned cheaply into each WhisperEngine.WhisperState is allocated fresh per transcribe() call β never reused. This is what allows concurrent inference on the same model."OpusHead" magic string β this check runs before the file extension check to correctly handle .ogg containers that carry Opus streams.AudioError variants must never be retried β they indicate a permanent condition (wrong format, missing file). Code in task.rs maps these to TaskError::Deterministic, which bypasses handle_failure.RABBITMQ_URL default vhost must use %2F encoding, not a bare / β lapin will reject an unencoded slash.{MODELS_DIR}/ggml-{WHISPER_MODEL}.bin β both variables are required for the path to be valid.entrypoint.sh auto-downloads the model from Hugging Face at container start if the file is missing.Builder stage: rust:1-bookworm + libopus-dev + libclang-dev + cmake
Runtime stage: debian:bookworm-slim + libopus0 + libgomp1 + libstdc++6
Dep-cache trick:
1. COPY Cargo.toml + stub main.rs β cargo build --release (caches all deps)
2. COPY src/ β cargo build --release (only recompiles ruistper itself)
After modifying src/:
docker-compose build --no-cache ruistper
(required to bypass the COPY src/ layer cache)
Ruistper is an async Rust microservice that listens to a RabbitMQ queue, transcribes each audio file using whisper.cppβ (GGML models), and publishes the result back to RabbitMQ. It is a drop-in replacement for the original Go + Python stack β the message format is identical.
Note on field names:
attachment_idandimport_batch_idcome from the original application's domain. The messaging contracts can and should be adapted to each use case β field names, types, and semantics are freely modifiable as long as both the producer and consumer agree.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Ruistper β
β β
β RabbitMQ βββΊ Consumer βββΊ WorkerPool βββΊ Producer βββΊ RabbitMQ
β β β β β
β β N workers β
β β ββββββββββββββββββββββββββββββββ β
β β β AudioPipeline β β
β β β ββ validate (size/duration) β β
β β β ββ decode (Symphonia/Opus) β β
β β β ββ resample β mono 16 kHz β β
β β β β β
β β β WhisperEngine β β
β β β ββ whisper.cpp (GGML) β β
β βββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Module | What it does |
|---|---|
main.rs | Entry point, initialises tracing |
app.rs | Wires everything together; handles startup and graceful shutdown |
config.rs | Reads and validates all configuration from environment variables |
retry.rs | Retry policy: MAX_RETRIES = 2, 5 s delay via Dead-Letter Exchange |
metrics.rs | Atomic counters: received / succeeded / failed / retried / in-flight |
shutdown.rs | Watch-channel pair + SIGINT/SIGTERM handler |
model/ | TranscriptionRequest and TranscriptionResult data structs |
audio/ | Full pipeline: validate β decode β resample to mono 16 kHz |
whisper/ | WhisperModel (loaded once at startup) + WhisperEngine (transcribes) |
messaging/ | AMQP connection pool, consumer, and producer |
worker/ | WorkerPool dispatcher + per-job task::process |
The service reads from:
whisper_exchange (direct)whisper_transcriptionstranscription.request{
"attachment_id": 123,
"audio_file_path": "/mnt/audio/voice_001.ogg",
"language": "es",
"import_batch_id": 42,
"retry_count": 0
}
| Field | Type | Required | Description |
|---|---|---|---|
attachment_id | i64 | β | Identifies the asset being transcribed |
audio_file_path | String | β | Absolute path to the audio file, visible inside the container |
language | String | β | ISO 639-1 hint ("es", "en", β¦). null = auto-detect |
import_batch_id | i64 | β | Passed through unchanged to the result |
retry_count | i32 | β | Managed automatically on retry; send 0 or omit |
Published to:
whisper_results_exchange (direct)whisper_resultstranscription.resultSuccess:
{
"attachment_id": 123,
"texto": "Hola, ΒΏcΓ³mo estΓ‘s?",
"duration": 4.32,
"model": "base-q5_1",
"success": true,
"import_batch_id": 42
}
Failure:
{
"attachment_id": 123,
"texto": "",
"duration": 0.0,
"model": "base-q5_1",
"success": false,
"import_batch_id": 42,
"error_message": "file not found: /mnt/audio/voice_001.ogg"
}
| Field | Type | Description |
|---|---|---|
attachment_id | i64 | Echoed from the request |
texto | String | Transcription text (empty on failure) |
duration | f64 | Audio duration in seconds |
model | String | Model identifier used |
success | bool | Whether transcription succeeded |
import_batch_id | i64 | null | Echoed from the request |
error_message | String | null | Present only on failure |
Jobs that fail due to a transient error (decode failure, inference error) are automatically retried up to 3 times total using a Dead-Letter Exchange pattern with a 5-second delay between attempts.
Main queue βββΊ [transient failure] βββΊ whisper_retry_exchange
β
whisper_retry_queue (TTL = 5 s)
β x-dead-letter-exchange
ββββΊ whisper_exchange (re-queued)
Deterministic errors are never retried. If the file is missing, the format is unsupported, or the file exceeds the size/duration limits, the service immediately publishes a failure result and acknowledges the message β no retry queue involved.
All settings come from environment variables. The service validates them at startup and will refuse to start if required values are missing or invalid.
| Variable | Default | Constraint | Description |
|---|---|---|---|
RABBITMQ_URL | amqp://guest:guest@localhost:5672/ | Valid AMQP URL | Use %2F for the default vhost (not a bare /) |
RABBITMQ_QUEUE_NAME | whisper_transcriptions | β | Input queue name |
WORKERS_COUNT | 4 | β₯ 1 | Concurrent transcription workers |
WHISPER_MODEL | base | β | Model ID, e.g. base-q5_1, small, large-v3 |
WHISPER_DEVICE | cpu | cpu / cuda / metal | Inference device |
MODELS_DIR | /app/models | β | Directory containing GGML .bin files |
MAX_FILE_SIZE_MB | 100 | β₯ 1 | Maximum accepted file size |
MAX_AUDIO_DURATION_SEC | 3600 | > 0 | Maximum accepted audio duration |
AUDIO_SAMPLE_RATE | 16000 | must be 16000 | whisper.cpp requires exactly 16 kHz |
TMP_DIR | /tmp/whisper | β | Directory for intermediate files |
API_HOST | 0.0.0.0 | β | HTTP API bind address (reserved, not yet active) |
API_PORT | 8080 | 1β65535 | HTTP API port (reserved, not yet active) |
RUST_LOG | ruistper=info,warn | tracing filter | Log level |
The model file is resolved as: {MODELS_DIR}/ggml-{WHISPER_MODEL}.bin
| Format | Extension | Decoder |
|---|---|---|
| OGG / Opus | .opus, .ogg (OpusHead magic bytes) | ogg + opus crates via libopus |
| MP3 | .mp3 | Symphonia |
| WAV | .wav | Symphonia |
| FLAC | .flac | Symphonia |
| AAC / M4A | .aac, .m4a | Symphonia |
| OGG / Vorbis | .ogg (non-Opus) | Symphonia |
All formats are resampled to mono 16 kHz f32 before inference. .wma is intentionally not supported.
# First run β builds the image, downloads the model, starts services
docker-compose up --build
# Rebuild after source changes (force-bypass layer cache)
docker-compose build --no-cache ruistper
docker-compose up
entrypoint.sh automatically downloads the GGML model from Hugging Face if it is missing:
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{WHISPER_MODEL}.bin
To pre-download manually:
mkdir -p models
MODEL=base-q5_1
curl -L "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${MODEL}.bin" \
-o "models/ggml-${MODEL}.bin"
Place this docker-compose.yml at the project root. Assumes Ruistper/ is a sibling directory and models are stored in a models/ bind mount.
services:
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672" # management UI β http://localhost:15672
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: admin
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 10s
timeout: 5s
retries: 10
ruistper:
build:
context: ./Ruistper
dockerfile: Dockerfile
container_name: ruistper
depends_on:
rabbitmq:
condition: service_healthy
volumes:
- ./models:/app/models # persists downloaded GGML models
environment:
RABBITMQ_URL: amqp://admin:admin@rabbitmq:5672/%2F
RABBITMQ_QUEUE_NAME: whisper_transcriptions
WORKERS_COUNT: "4"
WHISPER_MODEL: base-q5_1
WHISPER_DEVICE: cpu
MODELS_DIR: /app/models
MAX_FILE_SIZE_MB: "100"
MAX_AUDIO_DURATION_SEC: "3600"
TMP_DIR: /tmp/whisper
RUST_LOG: ruistper=info
restart: unless-stopped
Audio file visibility: Files must be accessible inside the container. Mount the audio directory as an additional volume (e.g.
- /data/audio:/mnt/audio) and use the container-side path inaudio_file_path(e.g./mnt/audio/voice_001.ogg).
Prerequisites:
libopus-dev (for OGG/Opus decoding)libclang-dev, cmake (for whisper.cpp compilation)cd Ruistper
cargo build --release
# binary: ./target/release/ruistper
| Crate | Purpose |
|---|---|
tokio | Async runtime |
lapin + deadpool-lapin | AMQP 0-9-1 client + connection pool |
serde + serde_json | JSON serialisation |
symphonia | Audio decode (MP3, WAV, FLAC, AAC, OGG/Vorbis) |
ogg + opus | OGG/Opus decode via libopus |
rubato | SincFixedIn resampler β mono 16 kHz |
whisper-rs | whisper.cpp bindings (GGML inference) |
tracing + tracing-subscriber | Structured logging |
Content type
Image
Digest
sha256:aa37ebdc7β¦
Size
51.3 MB
Last updated
8 months ago
docker pull lautaromvillalba/ruistper:v1.6