Audio Cue Templates
Per-podcast learned audio-cue (ding/stinger) templates (#350)
GET /feeds/{slug}/cue-templates
Section titled “GET /feeds/{slug}/cue-templates”List a feed’s audio-cue templates
Returns the feed’s own templates plus any network-scope templates shared from sibling feeds on the same network. Shared templates carry owned: false and are read-only in the UI (managed on the feed that created them).
Parameters
Section titled “Parameters”slug(path, string, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Templates for the feed | object |
| 404 | Feed not found |
200 response body
Section titled “200 response body”templates(array of CueTemplate)
POST /feeds/{slug}/cue-templates
Section titled “POST /feeds/{slug}/cue-templates”Mark a new cue template from a window of an episode
Requires the episode’s original (un-cut) audio to still be retained. Decodes the marked window, extracts an MFCC fingerprint, and stores the raw PCM as the source of truth. Selection must respect the configured capture minimum and the selected cue type’s maximum.
Parameters
Section titled “Parameters”slug(path, string, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”episodeId(string, required)startS(number, required)endS(number, required)cueType(string, one ofad_break_boundary,ad_break_start,ad_break_end,show_intro,show_outro,content_transition): Fixed cue type chosen from a dropdown (not freeform). The type sets the consistent label fed to the LLM and the matching role: ad_break_start/end snap that edge, ad_break_boundary snaps both, show_intro/show_outro never snap or pair and only tell the model the sound is the show open/close, not an ad, and content_transition never pairs and hints that a segment transition is there. It can snap an existing ad edge when the feed opts in, while retaining its audio. Defaultad_break_boundary.removeWithAd(boolean): Include a matched ad-break cue in the cut. Programme cues, including content transitions, keep their audio regardless of this option. Defaulttrue.cutMatches(boolean or null): Cut every match of this cue on its own. null inherits the feed or global setting. true is rejected for content_transition.scope(string, one ofpodcast,network): Defaultpodcast.networkId(string): Required when scope is network
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 201 | Template created | object |
| 400 | Invalid selection or cueType | |
| 404 | Feed or original audio not found |
201 response body
Section titled “201 response body”template(CueTemplate)
PATCH /cue-templates/{templateId}
Section titled “PATCH /cue-templates/{templateId}”Update cue template options, capture window, or scope
Parameters
Section titled “Parameters”templateId(path, integer, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”cueType(string, one ofad_break_boundary,ad_break_start,ad_break_end,show_intro,show_outro,content_transition): Change the cue type (also resets the derived label)enabled(boolean)removeWithAd(boolean): Include this ad-break cue in cuts. Omit to preserve the saved value; programme cues retain their audio.cutMatches(boolean or null): Cut every match of this cue on its own. null inherits; omit to keep the saved value. true is rejected for content_transition.scope(string, one ofpodcast,network)networkId(string): Required when promoting to network scopescoreThreshold(number or null): Per-template match-score threshold. Null clears the override (falls back to per-feed or global). Omit to leave unchanged. Minimum 0.30 (noise floor; sub-floor values pass noise as matches).sourceOffsetS(number): New window start (seconds into source episode). Supplying either sourceOffsetS or durationS triggers blob re-extraction from the retained original audio. Returns 409 when the original has been aged out.durationS(number): New window duration (seconds). Must respect the feed’s capture bounds (min capture seconds up to the cue type’s ceiling). Supplying either sourceOffsetS or durationS triggers blob re-extraction. Returns 409 when the original audio has been aged out.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Updated template | object |
| 400 | Invalid scoreThreshold value, non-numeric or negative window field, window duration outside the capture bounds, or a window whose decoded selection is too short to match. | |
| 404 | Template not found | |
| 409 | sourceOffsetS or durationS supplied but the template has no source episode, or the source episode original audio has been aged out (re-extraction is not possible); or the owning feed is in cue_only mode and this enabled/cueType change would leave it without an enabled ad_break_start or ad_break_end template. |
200 response body
Section titled “200 response body”template(CueTemplate)
DELETE /cue-templates/{templateId}
Section titled “DELETE /cue-templates/{templateId}”Delete a cue template
Parameters
Section titled “Parameters”templateId(path, integer, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Deleted | object |
| 404 | Template not found | |
| 409 | The owning feed is in cue_only mode and deleting this template would leave it without an enabled ad_break_start or ad_break_end template. |
200 response body
Section titled “200 response body”deleted(boolean)id(integer)
GET /cue-templates/{templateId}/export
Section titled “GET /cue-templates/{templateId}/export”Export a template as a portable zip (lossless FLAC + JSON manifest)
The zip manifest preserves removeWithAd alongside cue metadata and lossless audio.
Parameters
Section titled “Parameters”templateId(path, integer, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Zip archive | application/zip |
| 404 | Template not found | |
| 422 | Template has no raw audio to export |
GET /cue-templates/{templateId}/audio
Section titled “GET /cue-templates/{templateId}/audio”Stream raw audio for a cue template (inline WAV)
Parameters
Section titled “Parameters”templateId(path, integer, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Inline WAV audio for the template | audio/wav |
| 404 | Template not found | |
| 422 | Template has no raw audio stored |
POST /feeds/{slug}/cue-templates/import
Section titled “POST /feeds/{slug}/cue-templates/import”Import a cue template zip into a feed
Accepts a v2 zip (cue.flac) or a legacy v1 zip (cue.wav). The MFCC is recomputed from the decoded audio (a foreign blob is never trusted). Imports land as podcast scope. Sample-rate and channel mismatches are hard-rejected with no resampling. The manifest’s removeWithAd must be boolean; omission defaults to true.
Parameters
Section titled “Parameters”slug(path, string, required)
Request body (multipart/form-data, required)
Section titled “Request body (multipart/form-data, required)”file(string (binary), required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 201 | Template imported | object |
| 400 | Invalid zip, manifest, or audio | |
| 404 | Feed not found |
201 response body
Section titled “201 response body”template(CueTemplate)
POST /feeds/{slug}/episodes/{episode_id}/cue-scan
Section titled “POST /feeds/{slug}/episodes/{episode_id}/cue-scan”Run every enabled template against an episode (diagnostic)
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)
Request body (application/json)
Section titled “Request body (application/json)”scoreThreshold(number): Override the global threshold for this run only (clamped to 0-0.99)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Per-template peak scores and match times | object |
| 400 | Feed has no enabled templates | |
| 404 | Feed or original audio not found |
200 response body
Section titled “200 response body”episodeId(string)thresholdUsed(number)thresholdSource(string, one ofoverride,global,request): Source of the threshold value. ‘override’ = per-feed column; ‘global’ = global setting; ‘request’ = scoreThreshold body field.elapsedSeconds(number)templates(array of object)templates[].id(integer)templates[].label(string)templates[].durationS(number)templates[].peakScore(number)templates[].matchCount(integer)templates[].matches(array of CueMatch)
POST /feeds/{slug}/episodes/{episode_id}/cue-template-preview
Section titled “POST /feeds/{slug}/episodes/{episode_id}/cue-template-preview”Run a single template against an episode
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”templateId(integer, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Match times for the template | object |
| 404 | Template, feed, or original audio not found |
200 response body
Section titled “200 response body”templateId(integer)thresholdUsed(number)peakScore(number)matches(array of CueMatch)
GET /feeds/{slug}/episodes/{episode_id}/cue-loud-spots
Section titled “GET /feeds/{slug}/episodes/{episode_id}/cue-loud-spots”Template-free energy “loud spots” for the capture UI
Runs the band-passed energy detector over an episode’s original audio and returns candidate loud spots (not detected cues) as jump-to markers so the user can find a cue to bracket.
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Candidate loud spots | object |
| 404 | Feed or original audio not found |
200 response body
Section titled “200 response body”episodeId(string)loudSpots(array of object)loudSpots[].start(number)loudSpots[].end(number)loudSpots[].prominenceDb(number or null)
GET /feeds/{slug}/episodes/{episode_id}/detected-cues
Section titled “GET /feeds/{slug}/episodes/{episode_id}/detected-cues”Audio cues already found on an episode, as template candidates
Merges the episode’s persisted cue signals (template + spectral) with a template-free loud-spot pass over the original audio, labeled by source. Each is a candidate the user can promote into a per-feed cue template. Advisory only; nothing here changes the cut.
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Detected cue candidates | object |
| 404 | Feed not found |
200 response body
Section titled “200 response body”episodeId(string)hasOriginalAudio(boolean): False if the original audio was not retained (then loud spots are omitted and templates cannot be made)detectedCues(array of object)detectedCues[].start(number)detectedCues[].end(number)detectedCues[].source(string, one oftemplate,spectral,loud_spot)detectedCues[].label(string or null)detectedCues[].cueType(string or null)detectedCues[].score(number or null)detectedCues[].prominenceDb(number or null)
GET /feeds/{slug}/episodes/{episode_id}/cue-candidates
Section titled “GET /feeds/{slug}/episodes/{episode_id}/cue-candidates”Find cue-template candidates in an episode
On-demand scan combining within-episode recurring sounds (fingerprint
self-repeat, for ad-break stings) with cross-episode intro/outro segments
(head/tail audio shared across recent completed sibling episodes), so
candidates cover all cue types. Each candidate carries a kind and a
suggestedType hint. The decode is slow (90s+ on a long episode), so the
scan runs in a background thread and the result is cached. Poll this
endpoint until status is ready or error. Pass rescan=1 to force a
fresh scan after an error.
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)rescan(query, string, one of1,true,yes): Force a fresh scan, ignoring any cached resultpeek(query, string, one of1,true,yes): Read-only. Returns a cached result or ‘idle’ without starting a scan
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Scan status, with candidates once ready (intro/outro first, then recurring by count) | object |
| 404 | Feed or original audio not found |
200 response body
Section titled “200 response body”episodeId(string)status(string, one ofscanning,ready,error,idle):scanning(poll again shortly),ready(candidates attached),error(scan failed; seeerror), oridle(peek found no cached scan; none was started).error(string): Present when status is errorcandidates(array of object)candidates[].start(number)candidates[].end(number)candidates[].kind(string, one ofrecurring,intro,outro): recurring (repeats within the episode), or intro/outro (shared across sibling episodes)candidates[].count(integer or null): Recurring candidates only. Times the sound recurs within the episodecandidates[].episodeMatches(integer or null): Intro/outro candidates only. How many sibling episodes share the segmentcandidates[].suggestedType(string or null, one ofshow_intro,show_outro,ad_break_boundary,ad_break_start,ad_break_end,content_transition,null): Capture-type hint; intro/outro get show_intro/show_outro; recurring gets ad-affinity type or nullcandidates[].adBoundaryHits(integer or null): Recurring candidates only. How many occurrences fell near a known ad boundarycandidates[].boundaryAffinity(number or null (float)): adBoundaryHits / count; null when no ad history was availablecandidates[].affinitySource(string or null, one ofepisode,siblings,null): Whether affinity was computed from the scanned episode’s own ad history or from sibling episodes
POST /feeds/{slug}/episodes/{episode_id}/cue-candidates/dismiss
Section titled “POST /feeds/{slug}/episodes/{episode_id}/cue-candidates/dismiss”Dismiss a candidate span feed-wide
Fingerprints the given audio span and records a feed-wide dismissal so the sound is suppressed from future candidate scans across all episodes. The episode’s cached candidate scan is stamped immediately; other episodes self-correct on their next rescan.
Parameters
Section titled “Parameters”slug(path, string, required)episode_id(path, string, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”start_s(number, required): Start of the span to dismiss (seconds)end_s(number, required): End of the span to dismiss (seconds)label(string or null): Optional human-readable label for the dismissal
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 201 | Dismissal created | object |
| 400 | Invalid span, span exceeds 120 s maximum, or missing required fields | |
| 404 | Feed not found or original audio unavailable | |
| 503 | Audio fingerprinting failed |
201 response body
Section titled “201 response body”id(integer)label(string or null)startS(number)endS(number)sourceEpisodeId(string)
GET /feeds/{slug}/cue-candidate-dismissals
Section titled “GET /feeds/{slug}/cue-candidate-dismissals”List dismissed candidate sounds for a feed
Returns all feed-wide dismissals, newest first. These are the undo targets exposed in the UI.
Parameters
Section titled “Parameters”slug(path, string, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | List of dismissals | object |
| 404 | Feed not found |
200 response body
Section titled “200 response body”dismissals(array of object)dismissals[].id(integer)dismissals[].label(string or null)dismissals[].sourceEpisodeId(string)dismissals[].startS(number)dismissals[].endS(number)dismissals[].createdAt(string)
DELETE /cue-candidate-dismissals/{id}
Section titled “DELETE /cue-candidate-dismissals/{id}”Undo a cue candidate dismissal
Removes the dismissal record so the sound becomes suggestible again. The source episode’s cached candidate scan is unstamped immediately.
Parameters
Section titled “Parameters”id(path, integer, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Dismissal removed | object |
| 404 | Dismissal not found |
200 response body
Section titled “200 response body”deleted(boolean)
POST /cue-detections/{detectionId}/verdict
Section titled “POST /cue-detections/{detectionId}/verdict”Confirm, reject, or reset a cue detection’s review verdict
Advisory template-quality signal only. A verdict never changes an episode’s cut list; it records whether a template cue matched a real ad boundary so a feed’s cues can be judged and thresholds tuned.
Parameters
Section titled “Parameters”detectionId(path, integer, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”verdict(string, one ofpending,confirmed,rejected, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Updated verdict | object |
| 400 | Invalid verdict | |
| 404 | Cue detection not found |
200 response body
Section titled “200 response body”id(integer)verdict(string, one ofpending,confirmed,rejected)
GET /feeds/{slug}/cue-detections/advisory
Section titled “GET /feeds/{slug}/cue-detections/advisory”Per-feed cue health summary
Outcome and verdict counts, match-score range, and confirm rate across the feed’s processed episodes.
Parameters
Section titled “Parameters”slug(path, string, required)
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Per-feed cue advisory | CueAdvisory plus object |
| 404 | Feed not found |
200 response body
Section titled “200 response body”Includes every field of CueAdvisory.
templateHints(array of object): Per-template verdict hints. Only templates with enough rejections to classify are included.templateHints[].templateId(integer, required)templateHints[].label(string or null)templateHints[].hint(string, one ofraise_threshold,recapture, required): raise_threshold when rejections cluster just above the threshold; recapture when they span the score range or overlap confirmed scores.templateHints[].rejected(integer, required): Number of rejected detections for this template.templateHints[].confirmed(integer, required): Number of confirmed detections for this template.
POST /feeds/{slug}/cue-threshold-suggest
Section titled “POST /feeds/{slug}/cue-threshold-suggest”Suggest a global cue match threshold for a feed
Sweeps a sample of episodes for one or more templates and proposes a global cue match threshold based on the gap between noise and real matches. The suggestion is advisory; a cue below 0.80 confidence will not change cuts regardless of this value.
Parameters
Section titled “Parameters”slug(path, string, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”episodeId(string, required): Anchor episode to include in the sweep.rescan(boolean): Force a fresh scan even if results are cached.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Threshold suggestion result | object |
| 400 | No cue templates on feed, or invalid request | |
| 404 | Feed or episode not found |
200 response body
Section titled “200 response body”episodeId(string, required)status(string, one ofscanning,ready,error, required): scanning while results are being gathered, ready when a suggestion is available, error on failure.suggestion(object): Suggestion object. Present when status is ready.suggestion.confidence(string, one ofhigh,partial,low): high when signal is clearly above the effect floor; partial when signal is below the effect floor; low when no clear gap was found.suggestion.suggested(number or null): Proposed threshold (0-1), or null on low confidence.suggestion.noiseCeiling(number): Top of the noise cluster.suggestion.signalFloor(number): Bottom of the signal cluster.suggestion.gapWidth(number): Width of the gap between noise and signal.suggestion.signalCount(integer): Number of occurrences in the signal cluster.suggestion.effectFloor(number): Lowest confidence at which a cue can affect a cut on this install.suggestion.effectFloorWarning(string or null, one ofsignal-below-floor,null): signal-below-floor when the signal cluster sits below the effect floor; null otherwise.suggestion.scoresN(integer): Total number of occurrence scores used.suggestion.reason(string): Human-readable reason. Present on low confidence results.suggestion.labeledCounts(object): Breakdown of labeled scores used in the suggestion (confirmed and rejected counts). Present when labeled scores were available.suggestion.labeledCounts.confirmed(integer)suggestion.labeledCounts.rejected(integer)suggestion.labeledOverlap(boolean): True when the rejected and confirmed labeled score ranges overlap (no clean labeled placement is possible). Set when at least AUDIO_CUE_SUGGEST_MIN_LABELED total reviewed detections exist with both confirmed and rejected present, and the labeled score ranges overlap.sampleEpisodes(integer): Number of episodes included in the sweep.floorUsed(number): Low-floor threshold the sweep used to gather scores.currentThreshold(number): The threshold in effect for this feed at scan time (per-feed override if set, else global setting).scope(string, one offeed,global): Whether currentThreshold came from a per-feed override (‘feed’) or the global setting (‘global’).perTemplate(map of number): Map of templateId to peak score across the sampled episodes. Present when status is ready.error(string): Error message. Present when status is error.
POST /feeds/{slug}/cue-cross-episode-scan
Section titled “POST /feeds/{slug}/cue-cross-episode-scan”Find recurring audio segments across a set of episodes
Full-body cross-episode fingerprint scan. Finds audio segments that recur anywhere (not just intro/outro) across the supplied episode set. Between 2 and 5 episode IDs must be supplied; all must belong to this feed and have retained original audio. The scan runs in a background thread (full-duration fingerprinting is slow); poll this endpoint with the same body to check progress. rescan=true forces a fresh run even when a cached result exists. The result coordinate frame is the first supplied episode (targetEpisodeId). Candidates can be fed directly into the Make-template flow (CueMarkModal expects bounds + episode).
Parameters
Section titled “Parameters”slug(path, string, required)
Request body (application/json, required)
Section titled “Request body (application/json, required)”episodeIds(array of string, required): 2 to 5 episode IDs. All must belong to this feed and have retained original audio. The first entry defines the coordinate frame for returned candidate times.rescan(boolean): Force a fresh scan even if a cached result exists.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Scan status, with candidates once ready | object |
| 400 | Fewer than 2 or more than 5 episode IDs; an ID not in this feed; or an episode without retained original audio. | |
| 404 | Feed not found |
200 response body
Section titled “200 response body”status(string, one ofscanning,ready,error, required): scanning (poll again shortly), ready (candidates attached), error (scan failed; see error field).episodeIds(array of string): Echo of the requested episode IDs.targetEpisodeId(string): First episode ID; all candidate times are in this episode’s coordinate frame. Present when status is ready.candidates(array of object): Present when status is ready.candidates[].start(number)candidates[].end(number)candidates[].kind(string, one ofrecurring)candidates[].episodeMatches(integer): How many SIBLING episodes share this segment (the target episode always matches, so the total number of episodes containing it is episodeMatches + 1).candidates[].episodes(array of object): Per-episode breakdown: every occurrence of this candidate in each scanned episode (target first, then the remaining episodes in request order). Match times are in each episode’s own timeline. Absent on results cached by versions before 2.42.0; rescan to populate.candidates[].episodes[].episodeId(string)candidates[].episodes[].matchCount(integer): Occurrences in this episode (0 = not found).candidates[].episodes[].matches(array of object)candidates[].episodes[].matches[].start(number)candidates[].episodes[].matches[].end(number)error(string): Present when status is error.
POST /feeds/{slug}/cue-templates/{templateId}/optimize-window
Section titled “POST /feeds/{slug}/cue-templates/{templateId}/optimize-window”Find the window trim that maximises match score across sibling episodes
Grid-sweeps start/end deltas in -0.5..+0.5s (0.1s steps) to find the window geometry that yields the highest mean peak-correlation score across the source episode and up to 4 sibling episodes. Only the grid region of the source is decoded; each sibling is scanned once and all candidates are scored against a small neighborhood of its best baseline match. The scan runs in a background thread; poll this endpoint with the same body to check progress. rescan=true forces a fresh run. Returns 409 when the template has no source episode or the source episode original audio has been aged out (re-decoding the source window is required to score candidates).
Parameters
Section titled “Parameters”slug(path, string, required)templateId(path, integer, required)
Request body (application/json)
Section titled “Request body (application/json)”rescan(boolean): Force a fresh scan even if a cached result exists.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Scan status, with proposal once ready | object |
| 404 | Feed not found or template does not belong to this feed. | |
| 409 | Template has no source episode, or the source episode original audio has been aged out; optimizer cannot re-decode the source window. |
200 response body
Section titled “200 response body”status(string, one ofscanning,ready,error, required)templateId(integer, required)proposedStartS(number): Proposed window start (seconds). Present when ready.proposedEndS(number): Proposed window end (seconds). Present when ready.meanPeakScore(number): Mean peak ZNCC score of the proposed window. Present when ready.baselineMeanPeakScore(number or null): Mean peak score of the original window. Present when ready; null when the original window falls outside the live capture bounds and could not be scored.perEpisode(array of object): Per-episode peak scores for the proposed window. Present when ready.perEpisode[].episodeId(string)perEpisode[].peakScore(number)baselineWindow(object): Original window bounds. Present when ready.baselineWindow.startS(number)baselineWindow.endS(number)error(string): Present when status is error.
GET /cue-detections/aggregate
Section titled “GET /cue-detections/aggregate”Global cue telemetry for threshold tuning
The per-feed advisory shape across all feeds, plus a match-score histogram.
Responses
Section titled “Responses”| Status | Description | Body |
|---|---|---|
| 200 | Aggregate cue telemetry | CueAdvisory plus object |
200 response body
Section titled “200 response body”Includes every field of CueAdvisory.
scoreHistogram(array of object): Match-score buckets for above-threshold cues.scoreHistogram[].scoreFrom(number)scoreHistogram[].count(integer)nearMissHistogram(array of object): Score buckets for below_threshold near-misses (same bucketing as scoreHistogram). These never affected cuts.nearMissHistogram[].scoreFrom(number)nearMissHistogram[].count(integer)nearMissTotal(integer): Count of below_threshold advisory rows.unusedReasons(map of integer): Count per unused_reason across outcome=‘none’ cues (e.g. covered, out_of_reach, unpaired).