Sign inSign up

lautaromvillalba/ruistper

By lautaromvillalba

β€’Updated 8 months ago

Image
0

5.1K

lautaromvillalba/ruistper repository overview

⁠Ruistper


β πŸ€– LLM Context

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⁠.


⁠Project Identity
FieldValue
NameRuistper
Binaryruistper
LanguageRust, edition 2021, MSRV 1.78
Async runtimetokio multi-thread
RoleRabbitMQ worker β€” consumes audio jobs β†’ transcribes via whisper.cpp (GGML) β†’ publishes result back to RabbitMQ
OriginRust rewrite of a Go + Python stack; messaging contracts are protocol-identical to the original

⁠Crate Dependency Map
CrateVersionPurpose
tokio1Async multi-thread runtime (rt-multi-thread, macros, sync, fs, signal, time)
lapin2.3AMQP 0-9-1 client
deadpool-lapin0.12Connection pool over lapin
futures-util0.3StreamExt on consumer delivery stream
serde + serde_json1JSON serialisation / deserialisation
symphonia0.5Audio decode: MP3, WAV, FLAC, OGG/Vorbis, AAC, M4A
ogg0.8OGG container demux for Opus streams (bypasses Symphonia)
opus0.3OGG/Opus decode via FFI to system libopus
rubato0.15SincFixedIn resampler β†’ mono f32 @ 16 000 Hz
hound3WAV writing for optional intermediate files
whisper-rs0.13Unsafe bindings to whisper.cpp GGML C library
tracing0.1Structured, async-aware logging
tracing-subscriber0.3RUST_LOG env-filter subscriber

⁠Module Tree
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

⁠AMQP Topology
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

⁠Message Contracts

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
}
FieldRust typeRequiredNotes
attachment_idi64βœ…Asset identifier, echoed in result
audio_file_pathStringβœ…Absolute path inside the container
languageOption<String>βœ—ISO 639-1 ("es", "en", …); null = auto-detect
import_batch_idOption<i64>βœ—Passed through unchanged
retry_counti32βœ—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"
}
FieldRust typeNotes
attachment_idi64Echoed from request
textoStringFull transcript; "" on failure
durationf64Audio duration in seconds; 0.0 on failure
modelStringModel identifier used
successbool
import_batch_idOption<i64>Echoed from request
error_messageOption<String>Serialized only when success = false (skip_serializing_if)

⁠Error Classification
AudioError variantClassRetry?
FileNotFoundDeterministic❌
UnsupportedFormatDeterministic❌
FileTooLargeDeterministic❌
DurationExceededDeterministic❌
DecodeTransientβœ…
ResampleTransientβœ…
EngineError (all variants)Transientβœ…
JoinError (spawn_blocking panic)Transientβœ…

⁠Key Invariants
  1. AUDIO_SAMPLE_RATE must equal 16000 β€” whisper.cpp hard requirement; changing this breaks inference.
  2. WhisperModel is loaded exactly once at startup (expensive blocking call). Arc<WhisperModel> is cloned cheaply into each WhisperEngine.
  3. WhisperState is allocated fresh per transcribe() call β€” never reused. This is what allows concurrent inference on the same model.
  4. OGG/Opus detection reads the first 128 bytes for the "OpusHead" magic string β€” this check runs before the file extension check to correctly handle .ogg containers that carry Opus streams.
  5. Deterministic 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.
  6. RABBITMQ_URL default vhost must use %2F encoding, not a bare / β€” lapin will reject an unencoded slash.
  7. Model file path is resolved as {MODELS_DIR}/ggml-{WHISPER_MODEL}.bin β€” both variables are required for the path to be valid.
  8. entrypoint.sh auto-downloads the model from Hugging Face at container start if the file is missing.

⁠Docker / Build Notes
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)

⁠πŸ‘₯ Human Documentation


⁠What Is This?

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_id and import_batch_id come 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.


⁠Architecture
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          Ruistper                            β”‚
β”‚                                                              β”‚
β”‚  RabbitMQ ──► Consumer ──► WorkerPool ──► Producer ──► RabbitMQ
β”‚               β”‚             β”‚    β”‚                          β”‚
β”‚               β”‚          N workers                         β”‚
β”‚               β”‚          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚               β”‚          β”‚  AudioPipeline               β”‚  β”‚
β”‚               β”‚          β”‚  β”œβ”€ validate (size/duration) β”‚  β”‚
β”‚               β”‚          β”‚  β”œβ”€ decode (Symphonia/Opus)  β”‚  β”‚
β”‚               β”‚          β”‚  └─ resample β†’ mono 16 kHz   β”‚  β”‚
β”‚               β”‚          β”‚                              β”‚  β”‚
β”‚               β”‚          β”‚  WhisperEngine               β”‚  β”‚
β”‚               β”‚          β”‚  └─ whisper.cpp (GGML)       β”‚  β”‚
β”‚               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
ModuleWhat it does
main.rsEntry point, initialises tracing
app.rsWires everything together; handles startup and graceful shutdown
config.rsReads and validates all configuration from environment variables
retry.rsRetry policy: MAX_RETRIES = 2, 5 s delay via Dead-Letter Exchange
metrics.rsAtomic counters: received / succeeded / failed / retried / in-flight
shutdown.rsWatch-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

⁠Messaging Contracts
⁠Input β€” Transcription Request

The service reads from:

  • Exchange: whisper_exchange (direct)
  • Queue: whisper_transcriptions
  • Routing key: transcription.request
{
  "attachment_id":   123,
  "audio_file_path": "/mnt/audio/voice_001.ogg",
  "language":        "es",
  "import_batch_id": 42,
  "retry_count":     0
}
FieldTypeRequiredDescription
attachment_idi64βœ…Identifies the asset being transcribed
audio_file_pathStringβœ…Absolute path to the audio file, visible inside the container
languageStringβœ—ISO 639-1 hint ("es", "en", …). null = auto-detect
import_batch_idi64βœ—Passed through unchanged to the result
retry_counti32βœ—Managed automatically on retry; send 0 or omit
⁠Output β€” Transcription Result

Published to:

  • Exchange: whisper_results_exchange (direct)
  • Queue: whisper_results
  • Routing key: transcription.result

Success:

{
  "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"
}
FieldTypeDescription
attachment_idi64Echoed from the request
textoStringTranscription text (empty on failure)
durationf64Audio duration in seconds
modelStringModel identifier used
successboolWhether transcription succeeded
import_batch_idi64 | nullEchoed from the request
error_messageString | nullPresent only on failure

⁠Retry & Error Handling

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.


⁠Configuration

All settings come from environment variables. The service validates them at startup and will refuse to start if required values are missing or invalid.

VariableDefaultConstraintDescription
RABBITMQ_URLamqp://guest:guest@localhost:5672/Valid AMQP URLUse %2F for the default vhost (not a bare /)
RABBITMQ_QUEUE_NAMEwhisper_transcriptionsβ€”Input queue name
WORKERS_COUNT4β‰₯ 1Concurrent transcription workers
WHISPER_MODELbaseβ€”Model ID, e.g. base-q5_1, small, large-v3
WHISPER_DEVICEcpucpu / cuda / metalInference device
MODELS_DIR/app/modelsβ€”Directory containing GGML .bin files
MAX_FILE_SIZE_MB100β‰₯ 1Maximum accepted file size
MAX_AUDIO_DURATION_SEC3600> 0Maximum accepted audio duration
AUDIO_SAMPLE_RATE16000must be 16000whisper.cpp requires exactly 16 kHz
TMP_DIR/tmp/whisperβ€”Directory for intermediate files
API_HOST0.0.0.0β€”HTTP API bind address (reserved, not yet active)
API_PORT80801–65535HTTP API port (reserved, not yet active)
RUST_LOGruistper=info,warntracing filterLog level

The model file is resolved as: {MODELS_DIR}/ggml-{WHISPER_MODEL}.bin


⁠Supported Audio Formats
FormatExtensionDecoder
OGG / Opus.opus, .ogg (OpusHead magic bytes)ogg + opus crates via libopus
MP3.mp3Symphonia
WAV.wavSymphonia
FLAC.flacSymphonia
AAC / M4A.aac, .m4aSymphonia
OGG / Vorbis.ogg (non-Opus)Symphonia

All formats are resampled to mono 16 kHz f32 before inference. .wma is intentionally not supported.


⁠Running with Docker Compose
# 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"
⁠Docker Compose example

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 in audio_file_path (e.g. /mnt/audio/voice_001.ogg).


⁠Building Locally

Prerequisites:

  • Rust 1.78+
  • libopus-dev (for OGG/Opus decoding)
  • libclang-dev, cmake (for whisper.cpp compilation)
cd Ruistper
cargo build --release
# binary: ./target/release/ruistper

⁠Dependencies
CratePurpose
tokioAsync runtime
lapin + deadpool-lapinAMQP 0-9-1 client + connection pool
serde + serde_jsonJSON serialisation
symphoniaAudio decode (MP3, WAV, FLAC, AAC, OGG/Vorbis)
ogg + opusOGG/Opus decode via libopus
rubatoSincFixedIn resampler β†’ mono 16 kHz
whisper-rswhisper.cpp bindings (GGML inference)
tracing + tracing-subscriberStructured logging

⁠Developer contact

Tag summary

Content type

Image

Digest

sha256:aa37ebdc7…

Size

51.3 MB

Last updated

8 months ago

docker pull lautaromvillalba/ruistper:v1.6