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 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.txt2. 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.
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.
{
"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 — soline.textis exactlyprefix + textjoined across the line's words. Concatenating onlytextloses the spacing. - At
syncLevel: "line", every word'sstart_secandend_secarenull; only the line is timed. - Trailing text that received no timing appears as a final word with
nulltimes, rather than being dropped. annotationscarry ruby readings for kanji, pointing at a word by index.scoreis reserved and currently alwaysnull.
Full example (Python)
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 "")