> ## Documentation Index
> Fetch the complete documentation index at: https://cue.vibeset.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Loop a track over a long video

> Cover a clip that runs longer than the song.

Most licensed tracks run two or three minutes. When your video is longer than the song,
**Loop** stretches one track to cover the whole clip, not by slowing it down but by
finding a musical section that repeats. The loop is cut on downbeats over a whole
number of bars, so the beat never breaks at a seam, with automatic fades in and out.

<Note>
  Loop needs the video to be longer than the track by at least **30 seconds**. As a guideline it
  is for clips over 4 minutes. For shorter clips the track can play straight. See
  [Find where to cut](/docs/sync). Soundstripe tracks only.
</Note>

## Loop a track

Pass a `video_id` you've already ingested and a `track_id` from a match.

```bash theme={null}
curl https://cue.vibeset.ai/v1/loop \
  -H "x-api-key: vbsk_live_..." \
  -H "content-type: application/json" \
  -d '{"video_id": "69e5a58e73370adad5a10dc9", "track_id": "14792"}'
```

Cue serves a `200` from its loop-plan cache. The first time you loop a given track **against a
given video** there is nothing cached, so the plan is built as a job:

```json 202 first time theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "track_id": "14792",
  "status": "processing",
  "job_id": "run_9f2c…",
  "poll": "/v1/loop/jobs/run_9f2c…",
  "next": "Poll the job until status is 'complete', then read its loop plan."
}
```

A video whose ingest hasn't finished gives you a different `202`, with **no `job_id`** and a
`poll` of `/v1/videos/{video_id}`. Poll the video to `ready`, then call `/v1/loop` again.

Poll the loop job until it's complete:

```bash theme={null}
curl https://cue.vibeset.ai/v1/loop/jobs/run_9f2c… \
  -H "x-api-key: vbsk_live_..."
```

```json 200 the loop plan theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "track_id": "14792",
  "track": {
    "soundstripe_song_id": "14792",
    "soundstripe_audio_file_id": "60184"
  },
  "applicable": true,
  "plan": {
    "version": 15,
    "applicable": true,
    "music_in": 0.0,
    "music_out": 540.0,
    "loop_start_a": 12.6,
    "loop_end_b": 41.4,
    "bars": 16,
    "beats_per_bar": 4,
    "repeats": 18,
    "seam_score": 0.94,
    "segments": [
      { "song_from": 0.0, "song_to": 41.4, "gain_db": 0.0, "gain_in_db": 0.0 },
      { "song_from": 12.6, "song_to": 41.4, "gain_db": -0.4, "gain_in_db": -0.1 }
    ],
    "seam_fades": [0.31],
    "seam_curves": [0.72],
    "gain_glide_seconds": 1.2,
    "start_fade_seconds": 0.5,
    "end_fade_seconds": 2.0
  },
  "job_id": "run_9f2c…",
  "status": "complete",
  "cached": false
}
```

A `200` straight from `POST /v1/loop` carries the same body without `job_id`, `status` and
`cached`. `track` is on every loop response: the song id is the work, the audio-file id is the
specific render you fetch, and they are not interchangeable. The audio-file id is resolved
best-effort and is `null` when the catalog lookup fails.

## Reading the plan

`segments` is the authoritative playback path: the ordered ranges of the song to play back to
back. Video time is `music_in` plus the cumulative length of the segments played so far. Read
`segments` first. The flat fields below describe the same loop but cannot express a final pass
that exits into a come-down outro instead of being truncated mid-body.

| Field                                     | Meaning                                                                                                                                                  |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `segments[]`                              | `song_from` / `song_to` in seconds, plus `gain_db` (the segment's loudness-match gain) and `gain_in_db` (the gain it *starts* at, gliding to `gain_db`). |
| `gain_glide_seconds`                      | How long that glide takes. Equal gains mean nothing to smooth.                                                                                           |
| `seam_fades[]`                            | Crossfade length in seconds, one entry per internal splice, so `segments.length - 1` entries.                                                            |
| `seam_curves[]`                           | The gain-law exponent for the same splice, from `0.5` (equal power) to `1.0` (linear).                                                                   |
| `music_in` / `music_out`                  | Where the music starts and ends under the clip, in seconds. The music covers the whole clip.                                                             |
| `loop_start_a` / `loop_end_b`             | The looped body **within the song**, in seconds.                                                                                                         |
| `bars` / `beats_per_bar`                  | The body is a whole number of bars, so the beat grid is continuous across every seam.                                                                    |
| `repeats`                                 | How many times the body repeats to fill the clip.                                                                                                        |
| `seam_score`                              | Seam quality, 0–1. How smoothly the loop wraps, higher being smoother.                                                                                   |
| `start_fade_seconds` / `end_fade_seconds` | Fade the first entry in and the final pass out.                                                                                                          |

Apply `seam_fades`, `seam_curves` and the per-segment gains, or the seams click and step in level.
Full assembly steps are in [Render under your own license](/docs/own-license). They apply whether or
not you hold the license.

The plan carries more than the table above: `seam_times`, `song_start`, `song_duration`,
`window_end`, `music_end`, `tail_seconds`, `sections_covered`, `end_label`, an `outro_start` /
`outro_end` pair when the plan resolves on an outro, and an `options` array holding up to four
self-contained alternate plans, best first, with the served plan as `options[0]`. Treat anything
not in the table as informational.

## When a loop isn't applicable

If there's nothing to loop, `applicable` is `false`, the plan carries a `reason`, and **nothing is
billed**:

```json theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "track_id": "14792",
  "track": { "soundstripe_song_id": "14792", "soundstripe_audio_file_id": "60184" },
  "applicable": false,
  "plan": {
    "applicable": false,
    "reason": "video_not_longer",
    "video_seconds": 257.2,
    "track_seconds": 239.5,
    "required_excess_seconds": 30.0
  },
  "hint": "This video is 257.2s and this track is 239.5s, a margin of 17.7s — loop needs at least 30s. For this video, pick a track under 227s, or use /v1/align for sync points.",
  "retryable": false
}
```

`retryable` is the field to branch on. `true` means the analysis didn't finish on this request and
nothing was cached against the pairing, so calling again in a few seconds normally reads back work
that completed in the background. `false` means it's settled: pick another track.

| `reason`                                                              | `retryable` | What it means                                                                                                          |
| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- |
| `video_not_longer`                                                    | `false`     | The video doesn't clear the track by 30s. `video_seconds`, `track_seconds` and `required_excess_seconds` come with it. |
| `low_confidence`                                                      | `false`     | The track's beat isn't steady enough to splice without an audible seam.                                                |
| `no_viable_loop` / `no_loop_profile`                                  | `false`     | No section of this track repeats cleanly enough to cover the clip.                                                     |
| `timeout` / `profile_error` / `missing_profile` / `audio_unavailable` | `true`      | The build didn't finish on this attempt.                                                                               |

`video_seconds`, `track_seconds` and `required_excess_seconds` let you solve for the ceiling
yourself. For the clip above, any track under **227s** qualifies. Screening on those numbers
before you call is free. Calling and being refused is also free, but it costs a round trip per
track.

## Duck under voiceover (auto-mix)

If your video has narration or dialogue, you'll want the music to dip under the voice. Pass
`"auto_mix": true` and an **applicable** loop comes back with a `mix` block telling you how to
duck the music, using the same numbers the Cue app uses, so your render matches the app. There is no
`mix` key on a not-applicable result, and none when the measurement can't be read.

```bash theme={null}
curl https://cue.vibeset.ai/v1/loop \
  -H "x-api-key: vbsk_live_..." \
  -H "content-type: application/json" \
  -d '{"video_id": "69e5a58e73370adad5a10dc9", "track_id": "14792", "auto_mix": true}'
```

```json mix (on an applicable loop) theme={null}
{
  "mix": {
    "voiceover": true,
    "classification": "voice",
    "music_gain_db": -4.4,
    "video_gain_db": 11.4,
    "duck": {
      "envelope_points": [[0.0, 20.0], [1.2, 20.0], [1.225, 19.9], [1.9, 0.0], [4.8, 0.0]],
      "envelope_hz": 40,
      "speech_ranges": [[1.2, 4.8], [9.5, 14.1]],
      "depth_db": 20.0,
      "attack_ms": 375,
      "release_ms": 1425
    }
  }
}
```

### How to render it

Four steps, in this order. It is what Cue's own export does:

1. Take the footage's own audio and apply **`video_gain_db`**.
2. Take the music and apply **`music_gain_db`**.
3. On the music *only*, apply **`envelope_points`** as additional attenuation.
4. Sum the two, then limit.

`envelope_points` is `[[t_seconds, gain_db], …]` and it is the **authoritative curve**. It is
literally the automation Cue's own renderer replays. Read it as a positive amount to pull the
music **down** at that instant: `0` means untouched, `20` means 20 dB below where step 2 left it.
**Interpolate linearly between points**. That reproduces Cue's curve exactly, and no point of it
is approximated away. Points are emitted only where the curve moves, so long held passages cost
nothing: a real 257-second clip came to about 390 points.

<Warning>
  `depth_db`, `attack_ms` and `release_ms` are **nominal**. Ramp lengths are computed per gap, so
  these three describe a full-depth excursion and nothing else. A short pause gets a shallower,
  faster move than `depth_db` implies. They remain for callers who already render an approximation
  from them. **If you render from these instead of `envelope_points`, your mix will be audibly
  cruder than Cue's**, with the pumping on short gaps that the per-gap ramps exist to prevent.
</Warning>

When the clip has **no** voiceover you get `{ "voiceover": false, "classification": …, "duck":
null }`. There is nothing to duck, so play the music as planned, and `music_gain_db` and
`video_gain_db` are absent. Auto-mix reads the measurement warmed at ingest, so it adds no extra charge. The same
`auto_mix` flag works on [`/v1/align`](/docs/sync).

## What it costs

The cold build is billed per `(video_id, track_id)` pair, not once per track: looping the same
track over a second video builds a second plan and is charged at the cold rate again. Every later
loop of that same pairing is the warm rate. A not-applicable result bills nothing and `auto_mix`
adds no charge.

There is no `cached` field on a `200` straight from `POST /v1/loop`, because that `200` is served
from the plan cache by definition and is always the warm rate. `cached` appears on the completed
job instead, and there it is the field to read: a job normally builds the plan and bills cold, but
if another call cached the same pairing while it queued it comes back `cached: true` at the warm
rate. See [Metering & pricing](/docs/metering-and-pricing).
