feat(storage): precompute waveform peaks on the validator node - #522
Open
rickyrombo wants to merge 20 commits into
Open
feat(storage): precompute waveform peaks on the validator node#522rickyrombo wants to merge 20 commits into
rickyrombo wants to merge 20 commits into
Conversation
Adds an opt-in stage to mediorum that decodes audio the node already stores and reduces it to a 750-bucket amplitude envelope, so clients can render a waveform without downloading and decoding the track. The work happens where the blobs already are. Reading the 320 from the node's own bucket -- or, on the transcode path, straight off local disk before the temp file is removed -- means no peer fetch and no egress. Waveforms are deliberately local-only, not a crudr model. Mediorum rows replicate solely by riding the core chain as MediorumOperation txs, and the table allowlist those are validated against is consulted in FinalizeBlock, where result codes fold into the header. Registering the table would turn a rendering hint into consensus-affecting state and commit roughly a kilobyte per track to the chain permanently. Each node recomputes instead, and every input to the computation -- bucket count, sample rate, frame size -- is a compile-time constant so mirrors agree byte-for-byte. Notes on the implementation: - The existing audio analyzer is untouched. It runs inline in the transcode worker on a one-minute deadline and its errgroup cancels siblings on first error, so a waveform failure added there would kill BPM/key and write an error into the chain-submitted uploads row. Its WAV is also truncated to 120s, which is right for BPM/key and wrong for a full-track envelope. - Backfill needs its own discovery sweep: the analyzer's selector filters on audio_analysis_status != 'done' and the whole existing catalog is already done. - Read failures are split three ways. Both-buckets-NotFound is not_local; any other bucket error is unavailable on a short backoff. Collapsing them would let a transient archive outage stamp a 24h backoff across a large slice of the catalog in one sweep. Neither increments error_count, so a job still migrates to a node that has the file. - On a StoreAll node the archive tier holds the long tail, which is usually cold storage. Archive-tier CIDs are recorded as archive_skipped rather than read, so an operator can see the size of the bill in the status endpoint before opting in. - Duration comes from the decoded sample count, not uploads.ff_probe, which is probed from the original upload rather than the 320 and is null on older rows. computeWaveform also verifies it fed ffmpeg the whole source: a bucket read that ends early without erroring would otherwise yield a plausible waveform for a fraction of the track and mark it done. All three switches default to false, so an unconfigured node behaves exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stead Three review fixes. Move the transcode hook after the mirror bookkeeping. The waveform decode runs inline on a path HTTP handlers call synchronously, so sitting it between the transcode finishing and the node claiming to be a transcoded mirror delayed that claim for the length of a full-track decode. It stays inside the scope of the deferred remove, so the file is still on disk -- there was no race with that defer, only misplaced latency. Remove on-demand analysis from the serving path. It enqueued a job with no placementHosts, and analyzeWaveform performs its bucket read with no archive check, so an unauthenticated GET bypassed the archive-tier guard the backfill sweep applies -- on a StoreAll node that means anyone could trigger a cold-storage retrieval the sweep deliberately refuses, with no rate limiting in front of it. Coverage is already provided by the transcode hook and the backfill sweep, both of which respect that guard. Redirect to a peer that already has the waveform, mirroring what serveBlob does for blobs. Pointing at a node holding the answer is a better response than promising to compute one, and it addresses the ragged availability that comes with not replicating. The peer search cannot reuse hostHasBlob: holding the blob says nothing about holding the waveform, since waveforms are not replicated and a peer may have the feature switched off. Peers are probed for the waveform itself via HEAD, which the route now serves. The probe sets localOnly, and a node seeing localOnly answers 404 rather than forwarding -- without that a probe would recurse across the network. Rendezvous order is still the right search order, bounded to three probes since this sits inline in a user request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixes that turn out to share a mechanism. The backfill cursor latched exhausted forever. Uploads replicate from peers, so this node keeps learning about ones with timestamps the descending walk has already passed -- and it never went back for them, leaving those without waveforms permanently. Reaching the end of history now records the time, and a pass older than the re-walk interval starts over. That is cheap despite the table size: the not-exists filter means a batch skips straight to rows still lacking a waveform, so a converged backfill costs one query returning nothing rather than a scan per batch. Waveform version is now derived rather than hand-maintained. The parameters change the output just as surely as the algorithm does, so a hand-bumped constant invites exactly the mistake of changing waveformBuckets and silently leaving a network full of waveforms computed under the old settings. It is a fingerprint of the algorithm version and every parameter that affects output, which means it is not a sequence: comparisons are equality, never ordering, and the status endpoint now reports the inputs alongside it so the opaque number stays debuggable. Re-backfill then needs no separate sweep. The discovery query counts only rows at the current version as present, so a version change makes every stale row look absent and the ordinary walk recomputes them. The cursor records the version it walked under, and a mismatch restarts it immediately rather than waiting for the re-walk interval. The cursor's version column is folded into its create statement rather than added by a later alter, which is safe only because this branch is unmerged: that create is "if not exists", so any database that already ran the previous statement needs recreating rather than migrating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
not_local, unavailable and archive_skipped rows were written without a version, so they fell back to the column default and never matched the running one. Discovery decides an upload is outstanding by the absence of a row at the current version, which meant those uploads stayed permanently visible to it: every re-walk re-enqueued them, bypassing the backoff the retry sweep was applying. The effect was a missing blob being probed every re-walk rather than once a day, with discovery and the retry sweep scheduling the same cid. Each probe is only a bucket head, but on S3 those are billed, and archive_skipped rows exist precisely to avoid touching the bucket at all. Stamping the version hands scheduling to the retry sweep alone. Nothing is lost: that sweep keys off status and ignores version, so a version change still recomputes these rows once they succeed. not_local continues not to spend the retry budget, so a blob that replicates in later still gets a waveform however long it takes to arrive. The column default goes with it. Every write now supplies a version, and discovery's correctness depends on that, so a write that forgot one should fail loudly rather than silently insert a row no sweep will ever reconsider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The archive guard was applied where jobs are queued, which assumed a cid's tier is fixed. It is not: rendezvous rank shifts whenever the validator set changes, and replicateToMyBucket writes through bucketForCID, so a blob can become archive-tier after an earlier attempt already recorded not_local or unavailable. The retry sweep re-queues those rows without consulting the tier, and readBlob falls back to archive on NotFound unconditionally. A validator joining or leaving re-ranks a large slice of the catalog at once, so that combination could pull much of it out of cold storage with the flag switched off -- the exact bill the flag exists to prevent. Checking immediately before the first bucket call makes the flag authoritative however the job arrived. It costs nothing: isArchiveCID is rendezvous arithmetic over the in-memory host ring and touches no bucket, which is also why the discovery-time check is worth keeping as an optimization that avoids queueing work that would be skipped anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backfill dripped one batch of 100 per five-minute tick, a shape copied from the audio-analysis backlog sweep. That loop exists to pick up the occasional straggler the live path missed, and the rate suits it. It does not suit draining a whole catalog: at 28,800 a day a 2.4M-track node needs roughly three months, while two workers sit idle three quarters of the time. The sweep now refills the queue and stops when it is full, so throughput is set by how fast the workers drain it and OPENAUDIO_WAVEFORM_WORKERS becomes the knob that matters. It sweeps every few seconds while a backlog exists and falls back to a long interval once caught up, so an idle node is not querying constantly. The cursor may now only advance over uploads actually dealt with. Previously it advanced to the end of the batch whatever happened, and the enqueue result was discarded, so anything a full queue rejected was skipped until the next re-walk hours later -- silently, with no log and no counter. Backpressure replaces dropping. Two supporting changes. The decode pins itself to one ffmpeg thread: transcoding already caps itself at two per worker to avoid CPU spikes, decoding to PCM is single-threaded in practice, and backfill runs alongside that work. And a partial index on uploads (created_at desc, id desc) where template = 'audio' backs the keyset walk, which otherwise falls back to an index whose second column is transcoded_at and sorts a slice of a wide jsonb table -- worst of all during a caught-up re-walk, which reads to the end of history to prove nothing is left. That scan is also the one query here that grows with the catalog, so each sweep now runs under a timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Waveform analysis had no operator-facing surface: the only way to see whether it was running, how far it had got, or what it would cost to widen it was a basic-auth JSON endpoint. It lands as a section on the storage page rather than a page of its own. A backfill pass is the same shape as a repair run -- a cursor over local blobs with retries and failure states -- so it sits beside repair and borrows its run-card vocabulary. A top-level nav entry would also advertise a feature that is off by default on almost every node. Three cards. The run reports where the walk has reached and what the pass has queued. Stored reports the population by status. Needs Attention separates decode failures from bucket errors, because a rising unavailable count means storage is unhealthy rather than that audio is missing -- the reason those are distinct statuses at all. Two things the section is careful about, both being states operators get stuck in. All three switches are reported, not just the master: enabled with backfill off looks identical to enabled and working, and is the more common confusion. And a cursor still walking under an older version is called out, since between a version bump and the sweep noticing it, the only visible symptom is that nothing appears to have changed. Progress comes from where the cursor sits between the oldest and newest audio upload, and the projection from time elapsed in the pass. Uploads are not spread evenly across that span, so the estimate is an order of magnitude rather than a deadline -- labelled as such. The honest alternative is counting the rows left on every render, which is the scan this whole design avoids. Both bounds are ends of the walk's own index, so it costs two lookups whatever the catalog size. The cursor gains started_at and per-pass counters to support this, making it a run record rather than only a position. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to how the section presents. It is hidden entirely unless waveform analysis is enabled, matching how Archive Storage only appears once an archive bucket is configured. The feature is off by default, so an empty section on nearly every node was noise. The cost is discoverability -- an operator no longer learns the capability exists by reading the page -- which is the reason the previous version rendered a "disabled" line. Everything now sits inside a tile. The algorithm and settings were prose under the heading, but they are data describing the output, so they read as a tile like everything else on this page. The layout is two run tiles over two rows of stat tiles. Analysis carries the settings and which of the nested switches are on; Backfill carries the pass, its progress and its counters. Statuses become one stat tile each rather than a combined card, so unavailable sits beside the others while its sublabel keeps saying which problem it represents -- a bucket erroring, not audio missing. The request tiles are new, and needed new counters. Nothing previously reported whether anything consumes the waveforms at all. Redirects are the useful one: waveforms are computed per node rather than replicated, so the redirect rate is the closest thing to a measure of how evenly they are spread across the network. While only one node runs this, redirects stay near zero and misses stay high, which correctly reads as a coverage problem rather than a backfill problem. Peer probes carry localOnly and are answered without being counted as misses, so the counts describe client-facing traffic rather than the network searching itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
templ separates adjacent expressions with whitespace, so the percentage and the estimate rendered as "24.2% walked , ~19m12s left" with a space before the comma. Composing the line in Go keeps the punctuation where it belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tstanding work Three changes that turned out to depend on each other. Previews were never analyzed. A preview is its own blob with its own cid, and its waveform cannot be sliced out of the track's -- it is peak-normalized independently, and thirty seconds of a long track occupies too few buckets to stretch across a player. Discovery now enumerates the selected preview alongside the 320, and the transcode hook queues it once the preview blob exists. Serving needed nothing: the route is cid-addressed, so a preview waveform is servable the moment a row exists. Placement context is carried per blob rather than per upload, which previews forced into the open. The 320 is replicated with the upload's placement hosts and the preview with none, and bucketForCID reads any non-empty placement as "force primary" -- so handing a preview the upload's hosts would judge every preview primary-tier and read it out of cold storage with the archive flag off. Each target now carries what its own blob was written with, and the retry sweep recovers it by joining uploads rather than assuming nil, which also fixes placement-pinned 320s being wrongly skipped as archive-tier. waveforms gains a nullable upload_id. Not a key and not 1:1 -- an upload yields two analyzable blobs once previews count, and legacy Qm content has no upload row at all. It exists so discovery can correlate on indexed text instead of extracting jsonb from both sides, which was the expensive part of the sweep. An upload is outstanding until every blob it produced has a row at the current version, so a track whose preview is unanalyzed stays in the walk. That correlation is what makes an outstanding count affordable, and the console gains an Unanalyzed tile. It is the one figure describing rows absent from the table rather than present in it, so it is sampled on a sweep and the tile reports how stale the sample is instead of implying it is live. Analyzed now counts only the current version, so a stale row is no longer reported as both analyzed and awaiting recompute. The sweep loop also now runs regardless of backfill, with only the history walk gated. Returning early stranded the retry sweep, so a transient failure from the live transcode hook -- written with a next_attempt_at nobody read -- was permanent on a live-only node, and the outstanding count sat at zero while nothing had been looked at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rage Rename it to Skipped Archive, and say plainly what the number is: blobs skipped because they live in archive storage. It only renders when the node has an archive bucket configured. Without one, bucketForCID can never route anything to archive, so the tile was a permanent zero on every node that does not use archive storage -- which is most of them. The waveform archive flag is not the right condition: the count matters precisely when archive storage exists and that flag is off, since it is then the size of the bill for turning it on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rickyrombo
force-pushed
the
mjp-mediorum-waveform-peaks
branch
from
August 18, 2026 01:42
47eefdb to
44fca4f
Compare
A blob arriving by replication got no waveform until a sweep came back around -- typically the 24h not_local backoff, since the walk usually reaches an upload before replication does. The peer redirect hid the gap by bouncing clients to a node that had one, so the symptom was uneven coverage rather than failures. pullFileFromHostValidated already writes the blob to a temp file to check its cid before committing, so the bytes are on local disk at the moment the write lands. Analysis now reads that copy instead of fetching back what was just stored, which on an S3-backed node is a billed GET per replication. The job is queued rather than run inline, so the peer waiting on the request is not held for a decode. That is also why this is not teed off the bucket write: a wedged decode must not be able to stall replication, which is the path durability depends on. Queueing happens only after the write commits, so the peer is never told we hold a blob we have not finished storing. Ownership of the temp file transfers with the job. The enqueue reports definitively whether the send happened -- a non-blocking select either placed it or did not -- so a flag marks the transfer and the deferred cleanup still covers a copy error, a cid mismatch, a failed write, and a full queue. Once accepted, the worker releases it on success, failure and panic alike. The archive guard is skipped on this path. It exists to keep the backfill out of cold storage, and there is no bucket read here at all, so an archive-tier blob that arrives by replication is analyzed for free rather than deferred. Replication knows only a cid, but a row without an upload_id is invisible to discovery and its upload would stay outstanding forever, re-analyzed on every re-walk. The upload is resolved once, by index: the 320 directly, a preview through audio_previews to its source. Legacy Qm content resolves to null, which is correct rather than a failure. The bulk repair path is deliberately untouched. It streams straight into the bucket with no local copy, and it walks millions of blobs, so the sweeps continue to carry it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rickyrombo
force-pushed
the
mjp-mediorum-waveform-peaks
branch
from
August 18, 2026 06:41
43d7a3f to
a08b5bc
Compare
A waveform row written without an upload_id is invisible to discovery, which correlates on it. The upload then looks permanently outstanding, every re-walk recomputes a waveform that is already correct, and on a live-only node -- where discovery never runs -- nothing ever repairs it. Three ways that happened. The preview lookup in resolveWaveformUploadID joined audio_previews on source_cid, but gorm derives that column from SourceCID as source_c_id. The query errored on every call. Because the error was not ErrNoRows it was logged at Debug and the caller returned empty, which it reads as "legacy content with no upload" -- so every replicated preview was orphaned, silently. The suite never resolved a preview, so nothing caught it. The 320 had a narrower version of the same problem. The sender publishes the uploads row through consensus and then asks peers to pull over HTTP, so the puller can be handed a blob whose upload row it has not synced yet, and it was resolving against exactly that table. The pull request now carries the upload id the sender already had; resolution remains the fallback for peers that predate the field. Previews were also replicated before their audio_previews row was created, so the blob could reach a peer before anything on the network could account for it. Creating the row first does not close that window on its own -- it still has to clear consensus -- but it stops us widening it deliberately. Adds linkOrphanWaveforms as the backstop, repairing the link in place rather than recomputing. It runs regardless of the backfill flag, since a live-only node is where an unlinked row is permanent. Legacy Qm content is excluded: a null upload_id is the right answer there, and retrying it would never converge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stat tiles mixed two units. Analyzed counted rows in the waveforms table while Unanalyzed counted uploads missing them, so no arithmetic over the tiles produced the size of the catalog -- and an upload carrying a stale row was reported three times over: as analyzed, as awaiting recompute, and as outstanding, because the outstanding query only counted rows at the current version. Counting uploads throughout removes the overlap by construction. Every analyzable upload lands in exactly one bucket, so the tiles sum to the analyzable catalog. Where an upload's blobs disagree -- a finished 320 beside a failed preview -- the worse state wins: that is the one worth acting on, and filing it under both is what produced the double counting. Two counts were wrong independently of the units. To Recompute filtered on status = 'done', so a failed or not-local row at a stale version was counted by nothing at all -- and since a version change makes every row stale at once, the Failed tile would have dropped to zero the moment a parameter changed while the failures were still there. And an upload whose selected_preview named a blob that was never produced was expected to have two rows when only one could ever exist, leaving it permanently short. Expectation now mirrors waveformTargets, which requires the preview to resolve to a blob. Everything comes from one sampled pass rather than a query per tile. Sharing a snapshot is what lets the numbers reconcile; previously the live half and the sampled half described different moments. The console reports the age. Adds an Unlinked Rows tile, shown only when non-zero. The rollup is keyed by upload and structurally cannot see rows that resolved none, so this is the one figure that says the rest is describing a subset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every replicated blob comes through the pull handoff, not just the 320. replicateOriginal sends the original upload file down the same path, and image uploads travel it too, so each one cost an ffmpeg subprocess to produce a waveform for a cid nothing will ever request. A live archive node had already accumulated rows for two originals. Left alone this was about to get quieter rather than louder: the pull request now carries the sender's upload id, so those rows would arrive linked and stop appearing as unlinked. They would also count toward their upload's done total, and since expected is 1 for an upload with no preview, a waveform computed from the original alone satisfies it -- reporting the upload analyzed while the 320 that clients actually request has none. The sender knows which blob it replicated, so it says. Failing that -- an older peer, or a sender with no upload context -- resolveWaveformUploadID matches only a 320 or a selected preview, so a successful resolve is itself proof the blob is a target, and its miss is what filters the rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A preview was queued for analysis without its local file, so the worker read the blob back from the bucket -- where the archive guard refused it. Previews are rendezvous-routed with no placement, so on a StoreAll node bucketForCID sends one to the archive tier whenever this node's rank for the preview cid is >= ReplicationFactor. That cid ranks independently of the track's and of who created it, so creating a preview confers no standing: on a 70-peer network with a replication factor of 4, a node lands in the top four for roughly one preview in seventeen. The rest were recorded archive_skipped and never analyzed. A node running the recommended default -- StoreAll with archive analysis off -- therefore produced almost no preview waveforms at all, which is the configuration this feature was built for. generateAudioPreview still has the file on disk when it queues the job, so it hands it over on the same ownership-transfer the replication handoff uses: the worker deletes it, and the deferred cleanup covers every failure before the transfer is taken. The transcode hook's separate enqueue goes away with it, since the preview is now queued where it is produced -- which also covers the bare-CID HTTP path, previously not analyzed at all. This does not weaken the archive flag. That guard exists to prevent cold-storage retrievals, and there is no retrieval when the bytes are already on local disk; the 320 hook and the replication handoff make the same call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three failures on a live node turned out to be two bugs and one honest one. The decode cap and the short-read guard contradicted each other. ffmpeg is given -t waveformMaxDecodeSeconds, so on a longer source it exits at four hours and the copier stops with the source unread -- which the byte comparison cannot tell apart from a truncated blob. Nine sources between four and seven hours were reported as corrupt, each identically at 576,159,744 bytes, exactly four hours at 320kbps. The sample count separates the two cases, and a source longer than we decode is now terminal rather than a failure: nothing about the blob is wrong and no retry makes it shorter. Retries were also not bounded by their own cap. next_attempt_at only moves when an attempt finishes, and nothing marked a row as running, so a cid stayed selectable for the whole of its attempt and every sweep tick re-queued it. On a four-hour decode that is thousands of ticks: error_count reached 7 against a cap of 3, and did so in proportion to file size, worst for exactly the sources that were most expensive to decode. An in-flight set bounds it to one attempt per cid, released on every exit so a cid is never retired by a panic. Discovery also asked for more rows than could exist, expecting a preview whenever selected_preview was set rather than when it named a blob that was actually produced -- 55 uploads on that node. It now matches waveformTargets, the same correction the rollup already carries. The remaining six failures are real: blobs with no audio stream, one h263 video, and two mp3s whose demuxer wants to seek a pipe. Those keep the error status and its retry cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ped one waveforms stored next_attempt_at, a decision, while already stamping the fact it was derived from. Keeping only the fact is smaller in every direction. The column is now last_attempted_at, written before the analysis runs rather than after. That alone removes the re-queue loop: a row whose attempt is still going is inside its own backoff, so the sweep cannot select it. The in-flight set added for that goes away, along with its mutex and the release-on-every- path defer it needed -- and unlike the set, a timestamp survives a restart, so a node killed mid-attempt no longer finds every row it was working on instantly due again. It also retires a sentinel that had already caused a bug. next_attempt_at meant later when positive, already due when negative, and never when zero, which is how a test expressing "past due" as -1m was swallowed by the terminal check. Terminal is now a property of the status: done and too_long match no branch of the query, and nothing has to encode never in a timestamp. The retry query asks one status at a time so each branch is an equality on status and a range on last_attempted_at -- what (status, last_attempted_at) serves. A CASE over the backoff would have put an expression on the indexed side and turned it into a scan. Measured on 200k rows with three due: BitmapOr of three index scans, 0.058ms, 11 buffers. No ORDER BY. Picking a row up stamps it, so eligibility clears itself and every row is reached whatever order the batches arrive in; the heap is then read in physical order, which is better locality than seeking by timestamp anyway. waveformRetryBackoffUnavailable goes to 30 minutes, floored above waveformArchiveJobTimeout: a backoff shorter than the longest an attempt can run puts the row back in the sweep's reach while it is still working. Backoff policy now applies to rows already written, so tuning a constant no longer needs a migration or an UPDATE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ffmpeg was given -t 14400 as a poison-pill guard, which guarded nothing. The accumulator halves as it fills, so memory is fixed however long the audio runs, and the per-job context timeout already bounds wall clock -- which is the thing worth bounding, rather than duration standing in for it. What it did instead was fail nine real uploads on a live node, all of them sources between four and seven hours, and then require an apparatus to cope: a terminal status, a sentinel error, a sample-count test to tell a deliberate stop from a truncated read, branches in both analysis paths, a rollup bucket and a console tile. All of it existed to handle a limit that should not have been there. It also cost the short-read guard its meaning. That check says the source ended early, which is worth knowing; the cap forced it to also mean we stopped it on purpose, and the two were indistinguishable by byte count. A seven-hour track gives 34 seconds a bucket. Coarse, but that is what long content looks like, and someone asking for its waveform should get one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
What
An opt-in stage in mediorum that decodes audio the node already stores and reduces it to a 750-bucket amplitude envelope — per-bucket RMS, normalized to peak, quantized to uint8, 750 bytes a track. Clients render a waveform from that without downloading or decoding the audio.
The idea comes from a standalone prototype that pulled mp3s over the network from a single node via signed
/tracks/cidstream/{cid}requests. Running it on the node removes that egress entirely.Why mediorum and not the ETL
The ETL is a chain indexer with no blob access, so a detached process would have to fetch audio over the network — the exact cost being removed. Mediorum already owns the blobs, already runs ffmpeg, and already has a worker-pool/backlog/retry pattern to follow.
Why local-only and not replicated
Mediorum rows replicate solely by riding the core chain as
MediorumOperationtxs —syncCoreMediorumOpswalking committed blocks is the only path from one node to another; there is no peer-to-peer gossip. The table allowlist those txs validate against (opvalidation.mediorumTableTypes) is consulted byisValidMediorumOperationTx, which runs in FinalizeBlock, where result codes fold into CometBFT'sLastResultsHash.Registering
waveformsas a crudr model would therefore turn a derived rendering hint into consensus-affecting state requiring a coordinated network upgrade, and commit ~1KB per track to the chain permanently.So each node computes its own. Every input — bucket count, sample rate, frame size — is a compile-time constant rather than config, so two nodes produce byte-identical output and a client re-fetching from a different node after a reroute sees the same waveform. Replication remains available as a purely additive follow-up if it ever earns the cost; the table is CID-keyed either way.
Where waveforms come from
Four paths, none of which pays to read back a blob it just had:
waveformsrow, never reachingonError, because a rendering hint must not be able to fail an upload.pullFileFromHostValidatedalready buffers a pulled blob to disk to verify its cid, so it hands that copy to a waveform job once the bucket write commits — after commit, so the peer is never told we hold a blob we have not finished storing, and on the worker pool, so the peer is not held open for a decode. Without this a replicated blob waits for a sweep and pays a bucket read when one arrives.uploadsnewest-first behind a keyset cursor, so the recent slice lands first rather than after a full history walk.waveformstable only, so it is indexed and proportional to the result set rather than to catalog size.Throughput is set by the workers, not by a timer: a sweep refills the queue and stops when it is full, so
OPENAUDIO_WAVEFORM_WORKERSis the knob that matters. The retry and linking sweeps run whether or not backfill is enabled — only the history walk is gated — because those are what repair a transient failure from the live hook, and a live-only node would otherwise never retry one.The walk restarts after an interval rather than finishing permanently. Uploads replicate from peers carrying timestamps the descending walk has already passed, so "the end of history" is only ever true for now.
Linking a row to its upload
Rows carry
upload_id, and discovery correlates on it. That makes an unlinked row worse than untidy: it is invisible to discovery, so its upload looks permanently outstanding and every re-walk recomputes a waveform that is already correct — and on a live-only node, where discovery never runs, nothing ever repairs it.The paths differ in what they know:
upload.IDdirectly for both the 320 and the preview.uploadsrow the sender publishes through consensus — which arrives on a block commit, while the pull request arrived over HTTP. Resolving locally is kept as the fallback for peers that predate the field.resolveWaveformUploadIDcovers what is left: the 320 byidx_uploads_transcode_cid_320, a preview throughaudio_previewsto its source 320.linkOrphanWaveformsis the backstop, repairing the link in place rather than recomputing, on every sweep regardless of the backfill flag.Previews are also recorded before their blob is handed to peers. That does not close the window on its own — the row still has to clear consensus — but publishing a blob before anything can account for it is a gap worth not opening.
Serving
GET /waveform/:cidreturns{cid, version, buckets, sample_rate, duration_ms, peaks},peaksbase64-encoded because Go marshals[]bytethat way — divide each byte by 255 for a 0..1 envelope, pair withduration_msto render.On a miss it redirects to a peer that has it, mirroring
serveBlob. The search cannot reusehostHasBlob: holding the blob says nothing about holding the waveform, since waveforms are not replicated and a peer may have the feature switched off. Peers are probed for the waveform itself via HEAD. The probe setslocalOnly, and a node seeinglocalOnlyanswers 404 rather than forwarding — without that a probe would recurse across the network. Bounded to three probes, since this sits inline in a user request.Serving never triggers analysis. Enqueueing from an unauthenticated GET would bypass the archive-tier guard the sweeps apply, letting anyone trigger a cold-storage retrieval on a StoreAll node with no rate limiting in front of it. Pointing at a node that already holds the answer is a better response than promising to compute one.
Auth is
requireHealthy+ensureNotDelisted, deliberately withoutrequireRegisteredSignature. A 750-byte envelope is not decodable audio, and clients draw the waveform before playback begins, so requiring a stream signature would defeat the purpose. Calling that out as an explicit product decision. Delisting still applies — a waveform is derived from the audio and disappears with it.Previews
A preview is its own blob with its own cid, and its waveform cannot be sliced out of the track's: it is peak-normalized independently, and thirty seconds of a long track occupies too few buckets to stretch across a player. Discovery enumerates the selected preview alongside the 320. Serving needed nothing — the route is cid-addressed, so a preview waveform is servable the moment a row exists.
Placement context is carried per blob rather than per upload. The 320 is replicated with the upload's placement hosts and the preview with none, and
bucketForCIDreads any non-empty placement as "force primary" — so handing a preview the upload's hosts would judge every preview primary-tier and read it out of cold storage with the archive flag off.The preview is queued where it is produced, with its file handed over, rather than from the transcode hook afterwards. Nil placement means rank alone decides its tier, and a preview cid ranks independently of the track's and of who created it — so on a StoreAll node most previews route to that node's own archive tier, where a job arriving without a local file is refused by the archive guard. On a 70-peer network at a replication factor of 4 that is roughly sixteen previews in seventeen, which would leave the recommended configuration producing almost no preview waveforms at all. Queuing it at the point of production also covers the bare-CID
/generate_previewendpoint, which has no upload row to hook into.Versioning
Every row is stamped with a version, and discovery counts only rows at the current version as present. A version change therefore makes stale rows look absent and the ordinary walk recomputes them — no separate sweep, no table rewrite. The cursor records the version it walked under, and a mismatch restarts it immediately.
The version is derived rather than hand-maintained: a fingerprint over the algorithm version plus every parameter that affects output. A hand-bumped constant invites changing
waveformBucketsand silently leaving a network full of waveforms computed under the old settings.Two consequences:
algorithm_version,buckets, andsample_ratebeside it.Writes are
on conflict (cid) do update, so a recompute replaces the row in place. A CID keeps serving its existing waveform until the new one lands, and a version change never opens a coverage gap.Console
A section on the Storage page, beside Repair & Cleanup, since a backfill pass is the same shape as a repair run. It renders only when the feature is enabled, matching how Archive Storage appears only once an archive bucket is configured — the feature is off by default and an empty section on every node is noise.
Two run tiles (Analysis, Backfill) over stat tiles. Analysis carries the settings and which of the nested switches are on; Backfill carries the pass, its progress and its counters, with progress estimated from where the cursor sits between the oldest and newest audio upload and labelled rough, since uploads are not spread evenly across that span.
The stat tiles count uploads, not blobs. An upload yields a 320 and sometimes a preview, so a blob count and an upload count are different units and cannot be compared with each other — and mixing them is how a figure ends up in two tiles at once. Counted per upload, each analyzable upload lands in exactly one bucket and the tiles sum to the analyzable catalog: Analyzed, Never Analyzed, Partial, Not Local, Skipped Archive (only where archive storage exists), Unavailable, Failed, To Recompute. Where an upload's blobs disagree — a finished 320 beside a failed preview — the worse state wins, since that is the one an operator would act on.
Unlinked Rows sits alongside them and is shown only when non-zero: the rollup is keyed by upload and structurally cannot see rows that resolved none, so it is the one figure that says the rest is describing a subset.
A final row counts requests served, missed and redirected — nothing else reports whether the waveforms are being consumed, and the redirect count is the closest thing to a measure of how evenly they are spread across the network.
Notes for review
analyzeAudioruns inline in the transcode worker on a 1-minute deadline and its errgroup cancels siblings on first error, so a waveform failure added there would kill BPM/key and write an error into the chain-submitteduploadsrow. Its WAV is also truncated to 120s — right for BPM/key, wrong for a full-track envelope. And its selector filtersaudio_analysis_status != 'done', which the existing catalog already satisfies, so it could never backfill.waveformTargetsexactly, including its requirement that a selected preview actually resolve to a blob. Deriving it fromselected_previewalone lets an upload expect a row that can never be written, which leaves it short of its own expected count forever.waveforms.upload_idis nullable and not a key. An upload yields two analyzable blobs once previews count, and legacy Qm content has no upload row at all, so the relationship is not 1:1. Legacy content is excluded from linking for that reason — a null there is the right answer, and retrying it would never converge.audio_previews.source_c_id, notsource_cid. gorm derives the column fromAudioPreview.SourceCIDand splits the acronym. Getting it wrong does not fail loudly: the query errors, the error is notErrNoRows, and an empty return reads as "legacy content with no upload" — indistinguishable from success. The lookup is logged at Warn for that reason, and a test resolves a real preview.not_local; any other bucket error →unavailableon a short backoff. Collapsing them would let a transient archive outage stamp a 24h backoff across a large slice of the catalog in a single sweep. Neither incrementserror_count, which is what lets a job migrate to whichever node actually holds the file.enqueueWaveformJobreports the handoff definitively — a non-blocking send either placed the job or did not — so a flag marks the transfer and the deferred cleanup still covers a copy error, a cid mismatch, a failed write, and a full queue.uploads.ff_probe— that column is probed from the original upload rather than the 320, and is null on older rows.computeWaveformverifies it fed ffmpeg the whole source. A bucket read ending early without erroring would otherwise produce a plausible waveform for a fraction of the track and mark it done.sqrtapplied once at the bucket level, since RMS-of-RMS is wrong the moment a partial trailing frame exists.Config — all default false
OPENAUDIO_WAVEFORM_ENABLEDfalseOPENAUDIO_WAVEFORM_BACKFILL_ENABLEDfalseOPENAUDIO_WAVEFORM_ARCHIVE_ENABLEDfalsearchive_skipped.OPENAUDIO_WAVEFORM_WORKERS2They nest, so an operator can walk the cost curve one step at a time: nothing, then new uploads, then recent history, then the cold tier. A node that sets nothing behaves exactly as it does today.
Testing
54 tests — 41 across
waveform_test.go,waveform_link_test.goandwaveform_rollup_test.go, plus 13 covering the console section:localOnlyrefusing to forward, a cross-node 302, and a miss that provably enqueues nothing.audio_previews, orphans repaired in place, and legacy content left alone.Full
./pkg/mediorum/...and./pkg/core/console/...suites pass on freshly created databases;go build ./...,go vet, andgofmtclean.Verified end-to-end on a 4-node devnet: the live path produced a waveform immediately after transcode with
duration_msmatching a 200s track; a mirror produced byte-identical peaks; nodes with the feature off returned 404 with an empty table;select count(*) from ops where "table"='waveforms'was 0; a delisted CID returned 403.Also verified against a live archive node running this branch. A 180s synthetic track whose amplitude steps through 1.0 / 0.5 / 0.25 / 0.75 / 0.1 / 0.6 came back as 255 / 127 / 64 / 191 / 25 / 153 — the ±1s being uint8 quantization — with
duration_ms180000, confirming a full-length decode rather than a 120s truncation.Deferred
Legacy Qm CID discovery, and storing a peak envelope alongside RMS — wavesurfer's default rendering is per-bucket peak, so RMS reads flatter by comparison. The bulk repair path is also left on the sweeps deliberately: it streams straight into the bucket with no local copy, and it walks millions of blobs, so a decode per pull would slow the durability sweep for little gain. The
versioncolumn makes each a cheap follow-up.🤖 Generated with Claude Code