Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ make run-dev # Run with custom port (5092) for development
make models # Download int8 models (default, ~670MB)
make models-int8 # Download int8 quantized models
make models-fp32 # Download full precision models (~2.5GB)
make models-silero-vad # Download + verify the Silero VAD model (MIT, pinned)

# Test
make test # Run tests
Expand Down Expand Up @@ -67,6 +68,10 @@ parakeet/
├── internal/
│ ├── asr/
│ │ ├── transcriber.go # ONNX inference pipeline, TDT decoding
│ │ ├── chunker.go # Long-audio window planning + VAD/mel/midpoint boundaries
│ │ ├── boundary.go # Chunk-boundary oracle cascade (VAD -> mel energy -> midpoint)
│ │ ├── vad.go # Silero VAD ONNX session wrapper (shared, stateful via tensors)
│ │ ├── seam.go # Seam-level token dedup (absolute-timestep based)
│ │ ├── mel.go # Mel filterbank feature extraction (FFT, windowing)
│ │ ├── audio.go # WAV parsing, magic-byte detection, resampling to 16kHz
│ │ ├── ffmpeg.go # Optional ffmpeg-backed converter for non-WAV inputs
Expand All @@ -76,7 +81,9 @@ parakeet/
│ ├── server.go # HTTP server, route setup, lifecycle management
│ ├── handlers.go # API endpoint handlers, response formatting
│ └── types.go # Request/response type definitions
├── models/ # ONNX models (downloaded separately)
├── models/ # ONNX models (downloaded separately, incl. silero_vad.onnx)
├── testdata/
│ └── reference/ # Issue #18 reproduction audio + reference transcripts
├── bin/ # Build output directory
├── Makefile # Build recipes
├── Dockerfile # Multi-stage container build (CPU)
Expand All @@ -96,7 +103,7 @@ parakeet/

### `main.go` (Entry Point)

- Parses CLI flags: `-port`, `-models`, `-log-level`, `-log-format`, `-workers`, `-ffmpeg`, `-ffmpeg-path`, `-ffmpeg-timeout`, `-gpu`, `-gpu-device`
- Parses CLI flags: `-port`, `-models`, `-log-level`, `-log-format`, `-workers`, `-ffmpeg`, `-ffmpeg-path`, `-ffmpeg-timeout`, `-gpu`, `-gpu-device`, `-chunk-seconds`, `-chunk-overlap-seconds`, `-long-audio`, `-disable-vad-based-chunking`, `-disable-mel-based-chunking`, `-vad-model-path`
- Configures `slog` global logger (text or JSON handler, four log levels)
- Runs server in background goroutine, listens for SIGINT/SIGTERM
- Graceful shutdown: waits up to 30s for in-flight requests via `http.Server.Shutdown`
Expand All @@ -107,7 +114,7 @@ parakeet/

#### `server.go`

- `Config` struct: Port, ModelsDir, LogLevel, LogFormat, Workers, FFmpegEnabled, FFmpegPath, FFmpegTimeout, GPUProvider, GPUDeviceID
- `Config` struct: Port, ModelsDir, LogLevel, LogFormat, Workers, FFmpegEnabled, FFmpegPath, FFmpegTimeout, GPUProvider, GPUDeviceID, ChunkSeconds, ChunkOverlapSeconds, LongAudio, DisableVADBasedChunking, DisableMelBasedChunking, VADModelPath
- `Server` struct: wraps config, transcriber, `http.Server`, HTTP mux, and API key
- `New()` - Parses the GPU provider via `asr.ParseProvider` (fails fast on unknown values), initializes transcriber with worker pool, execution provider, and optional ffmpeg converter, reads `PARAKEET_API_KEY` env var, and sets up routes
- `Run()` - Starts HTTP listener (blocks until shutdown or error)
Expand Down Expand Up @@ -154,6 +161,14 @@ parakeet/
- `tdtDecode()` - TDT greedy decoding loop reusing pooled session and tensors
- `tokensToText()` - Token IDs to text with cleanup

#### `chunker.go`, `boundary.go`, `vad.go`, `seam.go` (Long-Audio Chunking)

- `planForAudio` / `planForAudioWithBoundaries` - Decide single-pass vs overlapping windows (long audio); the latter takes a boundary oracle.
- `planChunks` / `planChunksWithBoundaries` - Lay out overlapping windows and split each overlap's emission ownership; the boundary is chosen by the oracle and clamped so the emit ranges always tile the timeline.
- `boundaryOracle` interface with `vadBoundaryOracle`, `melEnergyBoundaryOracle`, `midpointBoundaryOracle`, chained by `chainBoundaryOracle` (cascade VAD -> mel energy -> midpoint). See DD-014.
- `sileroVAD` - Shared Silero VAD ONNX session; `vadState` carries per-request recurrent state + context so the session is safe to share (runs OUTSIDE the worker pool).
- `dedupSeam` - Drops window i+1's leading tokens that collide (in absolute encoder-frame timestep) with window i's tail; the earlier window wins. Always on, no flag.

#### `ffmpeg.go`

- `FFmpegConfig` - Public struct with `Enabled`, `BinaryPath`, `Timeout`
Expand Down Expand Up @@ -229,6 +244,12 @@ parakeet/
| `PARAKEET_API_KEY` | API key for `/v1/*` endpoint authentication | Empty (auth disabled) |
| `PARAKEET_GPU` | Execution provider: `cpu` or `cuda` | `cpu` |
| `PARAKEET_GPU_DEVICE` | GPU device index for `cuda` | `0` |
| `PARAKEET_LONG_AUDIO` | Split over-limit audio into overlapping chunks | `false` |
| `PARAKEET_CHUNK_SECONDS` | Sliding-window size in seconds | `300` |
| `PARAKEET_CHUNK_OVERLAP_SECONDS` | Overlap between chunks in seconds | `15` |
| `PARAKEET_DISABLE_VAD_BASED_CHUNKING` | Drop the Silero VAD boundary layer | `false` |
| `PARAKEET_DISABLE_MEL_BASED_CHUNKING` | Drop the mel-energy boundary layer | `false` |
| `PARAKEET_VAD_MODEL_PATH` | Path to silero_vad.onnx | `<models>/silero_vad.onnx` |

## Dependencies

Expand Down Expand Up @@ -330,7 +351,14 @@ No other external Go dependencies. Standard library used for HTTP, JSON, audio p
- `encoder-model.int8.onnx` (~652MB) or `encoder-model.onnx` (~2.5GB)
- `decoder_joint-model.int8.onnx` (~18MB) or `decoder_joint-model.onnx` (~72MB)
- `config.json`, `vocab.txt`, `nemo128.onnx`
- Download via `make models` or manually from HuggingFace
- `silero_vad.onnx` (~2.3MB, snakers4/silero-vad v6.2.1, MIT) - used only for VAD-aware chunk boundaries in long-audio mode. Missing file is a graceful degrade (warns once, falls back to mel energy), not a fatal error.
- Download via `make models` or manually from HuggingFace (Silero from its GitHub release)

### Chunk Boundary Selection (long-audio mode)

- With `-long-audio`, audio over the model's frame limit is split into overlapping windows. Each overlap's emission boundary is placed on silence by a cascade: Silero VAD, then smoothed mel energy, then the arithmetic midpoint (see DD-014).
- A second always-on safety net (`dedupSeam`) removes duplicate/colliding tokens right at each seam, using absolute encoder-frame timesteps; the earlier (warmed-up) window wins collisions.
- Tuning constants (VAD threshold, tolerances, smoothing) are named constants in `boundary.go`/`seam.go`, deliberately NOT flags. Flags only toggle whole layers (`-disable-vad-based-chunking`, `-disable-mel-based-chunking`) and the VAD model path (`-vad-model-path`).

### Tensor Memory Management

Expand Down
54 changes: 54 additions & 0 deletions .agents/DESIGN_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,57 @@ Key properties:
- CPU images, CPU mode, and the int8/fp32 CPU images are unaffected.
- **Known limitation**: the encoder runs in a single pass, so peak memory scales with audio length. On GPU this is bounded by VRAM — very long inputs can exceed device memory and fail with an ORT allocation error. Chunked/streaming encoding is the future fix.
- Other accelerators (TensorRT, ROCm, DirectML, OpenVINO) are out of scope; `buildSessionOptions` is the single place to add them.

## DD-014: VAD-Aware Chunk Boundaries and Seam Token Dedup

**Context**: Long-audio mode (PR #21) covers audio longer than the model's 5000 encoder-frame limit with overlapping windows and splits ownership of each overlap at its arithmetic MIDPOINT (`chunker.go`). Because the midpoint is blind to content it often lands mid-word. Each window timestamps tokens slightly differently, so a word straddling the boundary is emitted twice (duplicates like "to to") or by neither window (drops like "for., and how"). Issue #18 reproduces this on a ~30 minute file.

**Decision**: Move the boundary onto silence with a three-layer cascade, and add an always-on seam-level token dedup as a second safety net. Both operate only within the existing overlaps; the mission stays faithful to Parakeet TDT (no global segmentation, no silence skipping).

### Boundary selection cascade (interface-based oracles)

A `boundaryOracle` interface (`boundary.go`) picks the mel-frame split inside an overlap. The three implementations are alternative strategies for the same contract, tried in order by `chainBoundaryOracle`:

1. `vadBoundaryOracle`: runs Silero VAD over the overlap's waveform and picks the CENTER of the LONGEST run of 32 ms windows whose speech probability is below `vadSilenceThreshold` (0.4, a tuned constant in the agreed 0.35-0.5 band, NOT a flag). Silero's state is reset per overlap, so its first ~1 s is warmup (`vadWarmupWindows`); a boundary after the warmup is preferred, falling back to the warmup region only if no later silence exists. Declines (falls through) when nothing is below the threshold.
2. `melEnergyBoundaryOracle`: smooths per-frame energy from the already-extracted mel features (`melSmoothingFrames` ~150 ms) and returns the quietest frame in the overlap. Always decides when it has features, so it is the robust fallback when the VAD is disabled or its model is missing.
3. `midpointBoundaryOracle`: the arithmetic midpoint, the pre-#18 behaviour. Always decides, so the result is never worse than before.

`planChunksWithBoundaries` takes the oracle and CLAMPS whatever it returns into the legal overlap range, keeping the tiling invariant (`emitEnd[i] == emitStart[i+1]`, first `emitStart` 0, last `emitEnd` total, each emit range inside its window) for ANY oracle output. `planChunks` is now the nil-oracle (pure-midpoint) case, so the existing canary tests are unchanged.

**Why an interface**: the three layers are genuinely interchangeable implementations of one decision, and the cascade is just "try each until one decides". An interface makes each layer independently unit-testable from synthetic probability/energy arrays and lets the disable flags drop layers by simply not adding them to the chain.

**Why VAD only on overlaps**: the overlaps are short (15 s each by default), so the VAD cost is bounded and proportional to the number of seams, not the file length. Running Silero over the whole file would be the first step towards VAD segmentation, which this project deliberately rejects.

### Silero VAD integration

- Model file `silero_vad.onnx` lives in the `--models` directory like the other models, downloaded by the Makefile and Docker builds (NOT embedded in the binary). Pinned to release **v6.2.1** (MIT) with sha256 `1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3`, verified with `sha256sum -c` at download time. NOTE: at v6.2.1 the ONNX file is at `src/silero_vad/data/silero_vad.onnx`; the older `files/silero_vad.onnx` path no longer exists.
- One shared `DynamicAdvancedSession`, same pattern as the encoder (DD-011). Silero is stateful, but its recurrent state travels through the `state`/`stateN` TENSORS per call, not inside the session, so a single session is safe under concurrency as long as each request keeps its own `vadState`. The loop feeds 512-sample (32 ms) windows sequentially, prepending a 64-sample context and carrying the state between calls; v5+ uses the combined `state` tensor plus an `sr` sample-rate input, verified against the pinned model.
- **Missing model is not fatal**: `slog.Warn` once at startup ("VAD model not found, chunk boundaries fall back to mel energy") and degrade to the mel layer. Any other load error is fatal so a corrupt model surfaces loudly.
- **Concurrency caveat**: like the encoder, the VAD session runs OUTSIDE the decoder worker pool, so its concurrency is bounded by the number of in-flight HTTP requests, not by `-workers`.

### Seam token dedup (always on, no flag)

Operating on emitted tokens tagged with their ABSOLUTE encoder-frame timesteps (`decodedToken`, `seam.go`), `dedupSeam` drops a leading token of window i+1 when its timestep is within `seamTimestepToleranceFrames` (3 frames, ~240 ms) of any of window i's last `seamMaxTokens` (3) tokens. One rule covers both #18 failure modes: same text at the same position is a duplicate; different text at the same position is a collision that window i WINS (its LSTM reaches the seam fully warmed up while window i+1 is still warming). A duplicate further apart than the tolerance is kept. All tolerances are named constants with comments, not flags.

**Streaming**: `TranscribeStream` emits deltas as tokens are produced. To let dedup run before window i+1's head reaches the client, `tdtDecode` BUFFERS at most the first `seamMaxTokens` owned tokens of each window after the first, resolves them through the deduper, streams the survivors in order, then streams the rest as it decodes. Buffering is minimal (a handful of tokens per seam) and streaming order is preserved.

### Rejected alternatives

- **LCS / sequence stitching** of overlapping transcripts: fragile on repeated words, needs a similarity threshold that is itself a tuning knob, and operates on text after the timing information that actually identifies the seam has been thrown away. Placing the boundary on silence removes the ambiguity at the source; the timestep-based dedup is a cheap, deterministic backstop.
- **Full-file VAD segmentation**: would skip silence and re-segment globally, changing what audio Parakeet sees and breaking faithfulness to the model. Out of scope by mission.

### Known limitation

Loud or dynamic music has no quiet frame, so the mel-energy layer degrades to roughly midpoint quality; the VAD layer is the robust answer there because it distinguishes speech from non-speech energy rather than loud from quiet. With the VAD disabled AND music present, boundaries fall back to the midpoint, i.e. the pre-#18 behaviour.

**Configuration surface** (env vars come free via `applyEnvDefaults`):

- `--disable-vad-based-chunking` (bool, default false): drops layer 1.
- `--disable-mel-based-chunking` (bool, default false): drops layer 2.
- `--vad-model-path` (string, default `silero_vad.onnx` inside `--models`).

**Consequences**:

- New files: `internal/asr/boundary.go` (oracle stack), `internal/asr/vad.go` (Silero session), `internal/asr/seam.go` (dedup). `chunker.go` gains `planChunksWithBoundaries`/`planForAudioWithBoundaries`; `transcriber.go` threads absolute timesteps, the seam buffer, and the per-request oracle chain.
- `silero_vad.onnx` is a new required-for-VAD model file; its absence is a graceful degrade, not a failure.
- Reference reproduction material (the issue #18 MP3 plus `.srt`/`.vtt`/`.txt`) lives in `testdata/reference/`, with a build-tag-gated Go seam inspector (`-tags=seaminspect`) that prints transcribed vs reference text around every seam. No Python, no network.
1 change: 1 addition & 0 deletions .agents/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Pending tasks and improvements for the project.

## Performance

- [x] **VAD-aware chunk boundaries + seam dedup**: Fixes chunk-seam hallucinations (issue #18) in long-audio mode. Overlap ownership is split on silence via a VAD -> mel-energy -> midpoint cascade, and a seam-level token dedup removes duplicated/colliding tokens. Toggle layers with `-disable-vad-based-chunking` / `-disable-mel-based-chunking`; VAD model path via `-vad-model-path`. See DD-014.
- [x] **GPU inference** — Implemented via ONNX Runtime execution providers. Opt in with `-gpu cuda` / `-gpu-device N`; a dedicated `*-cuda` Docker image ships the GPU build. CPU remains the default. See DD-013. TensorRT and other accelerators remain out of scope (extend `buildSessionOptions`).
- [ ] **Batch inference support** — Current implementation processes one audio file at a time. Batching multiple requests could improve throughput under load.
- [ ] **Higher quality resampling** — `audio.go:resample()` uses linear interpolation. Sinc-based or polyphase resampling would improve audio quality.
Expand Down
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ models/

# Test files
test/
testdata/
*.wav

# Git
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ FROM debian:bookworm-slim
# Model precision: "int8" (default, ~670MB) or "fp32" (~2.5GB)
ARG MODEL_PRECISION=int8

# Silero VAD model for chunk-boundary selection (long-audio mode). Pinned tag +
# sha256 so the image build is reproducible and verified. snakers4/silero-vad is
# MIT licensed. At v6.2.1 the ONNX file lives at src/silero_vad/data/.
ARG SILERO_VAD_VERSION=v6.2.1
ARG SILERO_VAD_SHA256=1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3

# Install ONNX Runtime and ffmpeg (used for non-WAV audio conversion)
ARG ONNXRUNTIME_VERSION=1.25.1
ARG TARGETARCH
Expand Down Expand Up @@ -66,6 +72,8 @@ RUN mkdir -p /models && \
curl -L -o /models/config.json "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/config.json" && \
curl -L -o /models/vocab.txt "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/vocab.txt" && \
curl -L -o /models/nemo128.onnx "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/nemo128.onnx" && \
curl -L -o /models/silero_vad.onnx "https://github.com/snakers4/silero-vad/raw/${SILERO_VAD_VERSION}/src/silero_vad/data/silero_vad.onnx" && \
echo "${SILERO_VAD_SHA256} /models/silero_vad.onnx" | sha256sum -c - && \
if [ "$MODEL_PRECISION" = "fp32" ]; then \
curl -L -o /models/encoder-model.onnx "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/encoder-model.onnx" && \
curl -L -o /models/encoder-model.onnx.data "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/encoder-model.onnx.data" && \
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile.cuda
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ FROM ${CUDA_RUNTIME_IMAGE}
ARG MODEL_PRECISION=fp32
ARG ONNXRUNTIME_VERSION

# Silero VAD model for chunk-boundary selection (long-audio mode). Pinned tag +
# sha256 so the image build is reproducible and verified. snakers4/silero-vad is
# MIT licensed. At v6.2.1 the ONNX file lives at src/silero_vad/data/.
ARG SILERO_VAD_VERSION=v6.2.1
ARG SILERO_VAD_SHA256=1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3

# Install ONNX Runtime GPU build and ffmpeg (used for non-WAV audio conversion)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
Expand Down Expand Up @@ -92,6 +98,8 @@ RUN mkdir -p /models && \
curl -L -o /models/config.json "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/config.json" && \
curl -L -o /models/vocab.txt "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/vocab.txt" && \
curl -L -o /models/nemo128.onnx "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/nemo128.onnx" && \
curl -L -o /models/silero_vad.onnx "https://github.com/snakers4/silero-vad/raw/${SILERO_VAD_VERSION}/src/silero_vad/data/silero_vad.onnx" && \
echo "${SILERO_VAD_SHA256} /models/silero_vad.onnx" | sha256sum -c - && \
if [ "$MODEL_PRECISION" = "int8" ]; then \
curl -L -o /models/encoder-model.int8.onnx "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/encoder-model.int8.onnx" && \
curl -L -o /models/decoder_joint-model.int8.onnx "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/resolve/main/decoder_joint-model.int8.onnx"; \
Expand Down
Loading
Loading