Skip to content

Add job retention and adaptive pre-playback buffer for /v1/speak - #139

Merged
chazmaniandinkle merged 1 commit into
mainfrom
feat/adaptive-buffer-and-job-retention
Jul 23, 2026
Merged

Add job retention and adaptive pre-playback buffer for /v1/speak#139
chazmaniandinkle merged 1 commit into
mainfrom
feat/adaptive-buffer-and-job-retention

Conversation

@chazmaniandinkle

@chazmaniandinkle chazmaniandinkle commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Job retention. POST /v1/speak enqueues through server.py's own queue-based job registry (server._jobs, used by the MCP speech_status tool). GET /v1/jobs and GET /v1/jobs/{id} in http_api.py, however, only ever read http_api.py's separate _jobs dict — populated exclusively by /v1/synthesize, /v1/audio/speech, and /v1/vad. The result: a job launched via /v1/speak (the only endpoint that actually plays audio through the daemon) was always "not found" via the HTTP job-introspection endpoints, even mid-playback. On top of that, server.py's _prune_jobs() was a hard MAX_JOBS=20 count cap with no time dimension, so even a correctly-tracked job could be evicted seconds after launch under light concurrent traffic.

Playback gaps under GPU contention. adaptive_player.py's drain thread begins playback as soon as a small, fixed startup buffer fills. Measured on this machine: synthesis wall-clock time runs 1.26x normal when one concurrent local-LLM (LM Studio) call is in flight, since Metal is shared between local TTS and local LLM inference. Under that contention the fixed buffer isn't enough cushion and playback gaps.

Design

Job retentionGET /v1/jobs / GET /v1/jobs/{id} in http_api.py now merge two registries: this module's own job ledger and server.py's queue-based _jobs (imported lazily, same HTTP-only-mode guard already used elsewhere in the file). The live AdaptivePlayer reference some server._jobs entries carry is stripped before the merged dict crosses into the HTTP response. server.py's _prune_jobs() is now time-based: a finished job (done/error/cancelled) is kept until end_time is more than JOB_RETENTION_SECONDS (600s / 10 min) in the past; MAX_JOBS (bumped 20 → 500) stays as a hard safety net against unbounded growth under a flood. end_time is now stamped on every terminal transition (normal completion, setup error, and both cancel paths). The job_id format (uuid4().hex[:8]) is unchanged.

Adaptive pre-playback buffer — a two-loop policy in adaptive_player.py:

  • Feedforward (job start): compute_initial_buffer_ms() sizes the buffer from mod3's own recent chunk-deficit telemetry — a process-wide EMA of synth_time / audio_duration across recently generated chunks (record_chunk_deficit, fed from queue_audio's existing per-chunk metadata). Optionally corroborated by a ≤100ms-timeboxed GET http://localhost:1234/v1/models probe of the LMS lane as a contention proxy — a timeout (or the LMS lane not being up at all) is read as "assume quiet," never penalizing the estimate. Anchors: quiet ≤1.05x → 150ms, elevated 1.3x → 500ms (brackets the measured 1.26x), heavy ≥2.0x → capped at 2000ms.
  • Feedback (during playback): a true starvation event (buffer empty while the generator is still producing, not the expected end-of-drain) grows the target buffer by a fixed increment and holds output silent until the cushion rebuilds, rather than continuing to trickle out audio one small gap at a time. The target is monotonic — it never shrinks mid-utterance. Starvation events are counted.
  • initial_buffer_ms, final_buffer_ms, and starvation_count are recorded in the job's metrics (and therefore now visible via the merged /v1/jobs above).
  • The policy math (deficit_ema_to_buffer_ms, probe_latency_to_buffer_ms, compute_initial_buffer_ms, grow_target_buffer_ms) is pure — inputs to buffer_ms, no I/O, no class state — with AdaptivePlayer's runtime wiring kept thin around it. New env var MOD3_ADAPTIVE_BUFFER_PROBE=0 disables the LMS probe (falls back to the primary telemetry signal only).
  • This replaces the previous fixed MIN_BUFFER_SECONDS=1.5 / arrival-rate-EMA startup gate, which had no notion of synthesis falling behind real time.

Test evidence

New: tests/test_buffer_policy.py (28 tests — pure policy math, cross-job deficit-EMA telemetry, the LMS probe via a mocked httpx.Client, and the AdaptivePlayer wiring for starvation/rebuffering/recorded metrics, all without real audio hardware or network access) and tests/test_job_retention.py (13 tests — time-based prune behavior, and the merged /v1/jobs / /v1/jobs/{id} view including the regression case: a job that only exists in server._jobs, as /v1/speak creates, is now resolvable by id).

Rebased onto current main (ce42990) and ran the full suite there:

  • ruff check ., ruff format --check ., pyright --pythonversion 3.12 server.py http_api.py all clean.
  • pytest tests/ -q: 1052 passed, 1 skipped, 0 failed in ~16s — full suite, no regressions.

Deployment

Daemon restart required to take effect. The live daemon runs from the main checkout; this branch was built in an isolated worktree and does not touch the running process. Both features are inert until mod3 is restarted.

GET /v1/jobs and /v1/jobs/{id} now merge server.py's queue-based
/v1/speak job registry with http_api.py's own job ledger, closing an
observability hole where a job launched via /v1/speak was always
"not found" even mid-playback. server.py's _prune_jobs is now
time-based (JOB_RETENTION_SECONDS = 600) instead of a hard
MAX_JOBS=20 count cap, so finished jobs stay queryable for at least
10 minutes; MAX_JOBS (now 500) remains as a safety net. job_id format
is unchanged.

adaptive_player.py gains a two-loop adaptive pre-playback buffer:
feedforward sizing of initial_buffer_ms from mod3's own chunk-deficit
telemetry (plus an optional timeboxed LMS-contention probe) at job
start, and feedback growth + rebuffering on drain starvation
mid-utterance (never shrinks). initial_buffer_ms, final_buffer_ms,
and starvation_count are recorded in the job's metrics. The policy
math is pure and unit-tested independently of the audio runtime.

Requires a daemon restart to take effect — the live process is
unaffected until then.
@chazmaniandinkle
chazmaniandinkle force-pushed the feat/adaptive-buffer-and-job-retention branch from 7ca75cf to 4b97b97 Compare July 23, 2026 17:36
@chazmaniandinkle
chazmaniandinkle merged commit 198910c into main Jul 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant