Add job retention and adaptive pre-playback buffer for /v1/speak - #139
Merged
Merged
Conversation
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
force-pushed
the
feat/adaptive-buffer-and-job-retention
branch
from
July 23, 2026 17:36
7ca75cf to
4b97b97
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Job retention.
POST /v1/speakenqueues throughserver.py's own queue-based job registry (server._jobs, used by the MCPspeech_statustool).GET /v1/jobsandGET /v1/jobs/{id}inhttp_api.py, however, only ever readhttp_api.py's separate_jobsdict — 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 hardMAX_JOBS=20count 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 retention —
GET /v1/jobs/GET /v1/jobs/{id}inhttp_api.pynow merge two registries: this module's own job ledger andserver.py's queue-based_jobs(imported lazily, same HTTP-only-mode guard already used elsewhere in the file). The liveAdaptivePlayerreference someserver._jobsentries 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 untilend_timeis more thanJOB_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_timeis now stamped on every terminal transition (normal completion, setup error, and both cancel paths). Thejob_idformat (uuid4().hex[:8]) is unchanged.Adaptive pre-playback buffer — a two-loop policy in
adaptive_player.py:compute_initial_buffer_ms()sizes the buffer from mod3's own recent chunk-deficit telemetry — a process-wide EMA ofsynth_time / audio_durationacross recently generated chunks (record_chunk_deficit, fed fromqueue_audio's existing per-chunk metadata). Optionally corroborated by a ≤100ms-timeboxedGET http://localhost:1234/v1/modelsprobe 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.initial_buffer_ms,final_buffer_ms, andstarvation_countare recorded in the job's metrics (and therefore now visible via the merged/v1/jobsabove).deficit_ema_to_buffer_ms,probe_latency_to_buffer_ms,compute_initial_buffer_ms,grow_target_buffer_ms) is pure — inputs tobuffer_ms, no I/O, no class state — withAdaptivePlayer's runtime wiring kept thin around it. New env varMOD3_ADAPTIVE_BUFFER_PROBE=0disables the LMS probe (falls back to the primary telemetry signal only).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 mockedhttpx.Client, and theAdaptivePlayerwiring for starvation/rebuffering/recorded metrics, all without real audio hardware or network access) andtests/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 inserver._jobs, as/v1/speakcreates, 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.pyall 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.