Unison
Unison is a public
database of community-synced lyrics, the community-fed source Better Lyrics reads from. Each entry is
keyed on a videoId. Reading from it needs no key and no sign-in. Every
/lyrics response is wrapped as { success, data }, so check
success before you touch data. Reads are rate limited to 120 requests per
minute per IP.
Base URL
https://unison.boidu.dev Quick start
If you have a video id, one call gets you the top-ranked lyrics for it. The synced text is in
data.lyrics and its format is in data.format.
curl "https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ"const res = await fetch("https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ")
const { success, data } = await res.json()
if (success) {
// data.format is "ttml" | "lrc" | "plain"
// data.lyrics holds the synced text in that format
render(data.lyrics, data.format)
}import requests
res = requests.get("https://unison.boidu.dev/lyrics", params={"v": "dQw4w9WgXcQ"})
body = res.json()
if body["success"]:
data = body["data"]
# data["format"] is "ttml" | "lrc" | "plain"
render(data["lyrics"], data["format"])res, err := http.Get("https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ")
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
// body.data.lyrics holds the synced text in body.data.format
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))// reqwest = { version = "0.12", features = ["blocking"] }
let body = reqwest::blocking::get(
"https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ",
)?
.text()?;
// body.data.lyrics holds the synced text in body.data.format
println!("{body}"); Fetch by video
GET /lyrics?v=<videoId> returns the single best version for that video. The
fields you need to render are lyrics, format, and syncType;
confidence tells you how much the community has vetted it. A miss returns
404.
{
"success": true,
"data": {
"id": 4821,
"videoId": "dQw4w9WgXcQ",
"song": "Never Gonna Give You Up",
"artist": "Rick Astley",
"album": "Whenever You Need Somebody",
"duration": 213,
"format": "ttml",
"syncType": "richsync",
"language": "en",
"score": 42,
"voteCount": 47,
"confidence": "high",
"lyrics": "<tt xmlns=\"http://www.w3.org/ns/ttml\"> ... </tt>"
}
} Fetch by song and artist
No video id? Look up by song and artist instead. Both are matched
case-insensitively. Add album or duration (seconds, matched within a
small tolerance) when a title has more than one recording.
curl "https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley"const res = await fetch("https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley")
const { success, data } = await res.json()import requests
body = requests.get("https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley").json()res, err := http.Get("https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley")
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)let body = reqwest::blocking::get(
"https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley",
)?
.text()?; Search
GET /lyrics/search?q=<text> is for discovery. It returns ranked candidates with a
matchScore but no lyric body, so pick the videoId you want and fetch it
with /lyrics?v=. Passing song and artist instead of
q runs an exact match and does include the full records. Use limit to cap
results.
curl "https://unison.boidu.dev/lyrics/search?q=never+gonna+give"const res = await fetch("https://unison.boidu.dev/lyrics/search?q=never+gonna+give")
const { success, data } = await res.json()import requests
body = requests.get("https://unison.boidu.dev/lyrics/search?q=never+gonna+give").json()res, err := http.Get("https://unison.boidu.dev/lyrics/search?q=never+gonna+give")
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)let body = reqwest::blocking::get(
"https://unison.boidu.dev/lyrics/search?q=never+gonna+give",
)?
.text()?; {
"success": true,
"data": [
{
"id": 4821,
"videoId": "dQw4w9WgXcQ",
"song": "Never Gonna Give You Up",
"artist": "Rick Astley",
"format": "ttml",
"syncType": "richsync",
"confidence": "high",
"matchScore": 0.98
}
]
} Other versions
A video can have several submitted versions. GET /lyrics/variants/:videoId lists them
all, best-ranked first (up to 50), so you can offer alternatives if the default is not the one you
want. GET /lyrics/:id fetches one specific version by its numeric id.
curl "https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5"const res = await fetch("https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5")
const { success, data } = await res.json()import requests
body = requests.get("https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5").json()res, err := http.Get("https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5")
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)let body = reqwest::blocking::get(
"https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5",
)?
.text()?; Translating lyrics
POST /translate turns a block of lyrics into another language and romanizes it in one
call, so every client isn't hitting Google's rate-limited endpoint on its own. Send the
lines you want, the target language in to, and the source language in
from. Leave from out and Unison detects it, then echoes what it picked back
as detectedLang.
curl -X POST "https://unison.boidu.dev/translate" \
-H "content-type: application/json" \
-d '{"lines":["안녕하세요","반갑습니다"],"to":"en","from":"ko"}'const res = await fetch("https://unison.boidu.dev/translate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ lines: ["안녕하세요", "반갑습니다"], to: "en", from: "ko" }),
})
const { lines, detectedLang } = await res.json()import requests
body = requests.post("https://unison.boidu.dev/translate", json={
"lines": ["안녕하세요", "반갑습니다"],
"to": "en",
"from": "ko",
}).json()payload := strings.NewReader(`{"lines":["안녕하세요","반갑습니다"],"to":"en","from":"ko"}`)
res, err := http.Post("https://unison.boidu.dev/translate", "application/json", payload)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)// reqwest = { version = "0.12", features = ["blocking", "json"] }
let body = reqwest::blocking::Client::new()
.post("https://unison.boidu.dev/translate")
.json(&serde_json::json!({
"lines": ["안녕하세요", "반갑습니다"],
"to": "en",
"from": "ko",
}))
.send()?
.text()?;
Each line comes back with a translation, a romanization when the source
isn't already in the Latin alphabet, and a needsTranslation flag so you can leave lines
that were already in the target language alone. Answers are cached per lyrics-and-language pair, so
the same request only reaches Google once; a cache hit sets cached to
true. Blank lines and lone ♪ markers keep their place and come back empty.
{
"lines": [
{ "translation": "Hello", "romanization": "annyeonghaseyo", "needsTranslation": true },
{ "translation": "Nice to meet you", "romanization": "bangapseumnida", "needsTranslation": true }
],
"detectedLang": "ko",
"provider": "google-lyrics-translate",
"cached": false
}
When from and to are the same language there's nothing to do, so every
line comes back with a null translation and needsTranslation false instead
of a copy of the original. A request with no real lyric lines, or one where the source language
can't be worked out, returns 400; more than 200 lines or an overlong line or language
code returns 422; if Google is unreachable or rate limited you get 502 or
503. A transient failure isn't cached, but the same lines failing to parse three times
over is remembered as a negative and short-circuits to 502 for about a month so the
upstream isn't hammered.
Formats
The format field tells you how to read lyrics, and syncType
tells you how precise the timing is.
-
ttmlis timed XML, the richest format. Pair it withsyncTyperichsyncfor syllable-level timing orlinesyncfor per-line timing. lrcis timestamped lines, the classic karaoke format.plainis unsynced text, withsyncTypeplain.
Errors and limits
Failed /lyrics calls keep the envelope with success: false plus a
code and a human-readable error and hint. Common ones are
404 when nothing matches and 400 when the query is missing a required
param. Stay under 120 requests per minute per IP or you get 429.
{
"success": false,
"error": "Not found",
"code": "NOT_FOUND",
"hint": "No lyrics matched that lookup yet."
}