Guides

Text Sync

Align a written lyric to a recording and get timings back — per line, and optionally per word for karaoke highlighting. Unlike the separation models, this one takes a second input and returns a JSON document instead of an audio track.

What is different here

Every other model is one audio file in, audio tracks out. A text_sync target additionally needs the lyric as its own asset and two parameters, and it delivers json. Everything else — uploading, job creation, webhooks, polling, link expiry — works exactly as it does elsewhere.

1. Upload the audio and the lyric

Both are ordinary assets, created the same way (see Quickstart for the upload flow). The lyric must be UTF-8 plain text with one lyric line per line — the line breaks are what the timings are attached to. It is rejected at job creation if it is not valid UTF-8, or if it is larger than 256 KiB, which is far above any real lyric and is there to catch an audio file uploaded into the lyric slot by mistake.

Upload the lyric
# Upload the lyric exactly like any other asset — it is just a small text file.
curl -X POST https://api.dev.developers.gaudiolab.io/v1/assets \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fileName": "song.txt", "fileSize": 2375}'

# → { "assetId": "as_2b81de", "upload": { "mode": "single", "url": "https://..." } }

curl -X PUT "$UPLOAD_URL" --data-binary @song.txt

2. Create the job

The audio stays where it always is — the job's assetId. The lyric goes on the target, as inputs.lyricsAssetId. This is per target rather than per job so one job can carry targets that have nothing to do with a lyric, and so the same recording can carry two lyric targets in different languages.

params.language is required (en, ko, ja) and params.syncLevel selects the granularity: word (the default) times every word, line times only the lines. Requesting line when you need word highlighting cannot be fixed after the fact — the words carry no times at all in that mode.

Create the job
curl -X POST https://api.dev.developers.gaudiolab.io/v1/jobs \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "assetId": "as_7f3a9c",
    "targets": [{
      "model": "lyrics_sync_v1",
      "formats": ["json"],
      "inputs": { "lyricsAssetId": "as_2b81de" },
      "params": { "language": "ja", "syncLevel": "word" }
    }]
  }'

Length limit

This model accepts up to 30 minutes of audio. A longer input is rejected when the job is created, not after processing.

3. Read the result

Collect the job exactly as you would any other (webhook or polling). The deliverable is a single JSON document at output.lyrics_sync.json.

Result shape
{
  "metadata": {
    "format_version": "0.1.0",
    "duration_sec": 213.44,
    "main_language": "ja"
  },
  "data": {
    "text": "line 1 lyrics\n라인 2 가사\nライン3 歌詞",
    "lines": [
      {
        "index": 0,
        "start_sec": 12.48,
        "end_sec": 15.02,
        "sync_level": "word",
        "words": [
          { "prefix": "",  "text": "line",   "start_sec": 12.48, "end_sec": 12.91 },
          { "prefix": " ", "text": "1",      "start_sec": 12.91, "end_sec": 13.30 },
          { "prefix": " ", "text": "lyrics", "start_sec": 13.30, "end_sec": 15.02 }
        ],
        "annotations": [],
        "score": null
      }
    ]
  }
}

Four things are worth knowing before you render it:

  • Each word carries a prefix — the separator in front of it — so line.text is exactly prefix + textjoined across the line's words. Concatenating only text loses the spacing.
  • At syncLevel: "line", every word's start_sec and end_sec are null; only the line is timed.
  • Trailing text that received no timing appears as a final word with null times, rather than being dropped.
  • annotations carry ruby readings for kanji, pointing at a word by index. score is reserved and currently always null.

Full example (Python)

lyric_sync.py
import os, time, requests

BASE = "https://api.dev.developers.gaudiolab.io/v1"
H = {"x-api-key": os.environ["API_KEY"], "Content-Type": "application/json"}


def upload(path: str) -> str:
    """Create an asset and push the bytes. Same call for audio and lyrics."""
    data = open(path, "rb").read()
    created = requests.post(
        BASE + "/assets",
        headers=H,
        json={"fileName": os.path.basename(path), "fileSize": len(data)},
    ).json()
    requests.put(created["upload"]["url"], data=data).raise_for_status()
    return created["assetId"]


audio_id = upload("song.wav")
lyric_id = upload("song.txt")          # UTF-8, one lyric line per line

job = requests.post(
    BASE + "/jobs",
    headers=H,
    json={
        "assetId": audio_id,
        "targets": [
            {
                "model": "lyrics_sync_v1",
                "formats": ["json"],
                "inputs": {"lyricsAssetId": lyric_id},
                "params": {"language": "ja", "syncLevel": "word"},
            }
        ],
    },
).json()

while True:                             # or set webhookUrl and skip the poll
    state = requests.get(f"{BASE}/jobs/{job['jobId']}", headers=H).json()
    if state["status"] in ("completed", "failed"):
        break
    time.sleep(10)

target = state["targets"][0]
if target["status"] != "completed":
    raise SystemExit(target["error"])

result = requests.get(target["output"]["lyrics_sync"]["json"]).json()
for line in result["data"]["lines"]:
    print(line["start_sec"], line["end_sec"], line["text"] if "text" in line else "")