QRC format
The QQ provider (/qq/getLyrics) returns lyrics in QRC, a format with word-level
timing. This page describes the response and how to parse it.
Response structure
{
"lyrics": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<QrcInfos>...</QrcInfos>",
"provider": "qq"
} | Field | Type | Description |
|---|---|---|
lyrics | string | An XML document that wraps the QRC body |
provider | string | Always qq |
The XML envelope
The QRC body is not an element of its own. It is the value of the LyricContent
attribute on <Lyric_1>, inside <QrcInfos><LyricInfo>.
The attribute value spans several lines.
<?xml version="1.0" encoding="utf-8"?>
<QrcInfos>
<QrcHeadInfo SaveTime="306" Version="100"/>
<LyricInfo LyricCount="1">
<Lyric_1 LyricType="1" LyricContent="[ti:Wanderlust]
[ar:The Weeknd]
[al:Kiss Land]
[by:]
[offset:0]
[5767,324]Wanderlust - (5767,109)The (5876,106)Weeknd(5982,109)
...
[292333,1910]Let (292333,136)it (292469,97)come (292566,202)to (293301,377)you(293678,565)
"/>
</LyricInfo>
</QrcInfos>
Because the body is an attribute value, a double quote or ampersand inside it arrives as
" or &. Decode these after you extract the body.
Header tags
The body starts with LRC-style tags. Any of them can be empty.
| Tag | Meaning |
|---|---|
[ti:] | Title |
[ar:] | Artist |
[al:] | Album |
[by:] | Author of the lyrics file |
[offset:] | Timing offset in milliseconds |
Header tags carry no lyrics. The braccato parser skips every tag of the form
[letters:...], including [offset:].
Line structure
Each lyric line starts with [startMs,durationMs]. Then come the words, and each word
is followed by its own timing token (startMs,durationMs):
[292333,1910]Let (292333,136)it (292469,97)come (292566,202)to (293301,377)you(293678,565) This line breaks down as:
| Text | Start (ms) | Duration (ms) |
|---|---|---|
| line | 292333 | 1910 |
"Let " | 292333 | 136 |
"it " | 292469 | 97 |
"come " | 292566 | 202 |
"to " | 293301 | 377 |
"you" | 293678 | 565 |
The text comes before its timing token. A word is all the text between the end of the previous token (or the line header) and the start of its own token. The space between words belongs to the word before it, so join the words as they are to rebuild the line.
Time format
- All times are integers in milliseconds.
- Word start times are absolute, measured from the start of the song, not from the start of the line.
- The line header gives the start and length of the whole line.
- There can be gaps between words. A word ends at
start + duration, not where the next word starts. - A duration of
0can occur. The braccato parser then uses the start of the next line, or the song duration for the last line.
Credit and title lines
The first lines of a QRC body often repeat the title and artist, or list credits. These are timed like lyrics, so a naive parser shows them as sung lines:
[5767,324]Wanderlust - (5767,109)The (5876,106)Weeknd(5982,109)
[6091,2858]Lyrics (6091,125)by:(6216,117)QUENNEVILLE/(6333,120)...
[8949,3816]Composed (8949,126)by:(9075,162)QUENNEVILLE/(9237,144)...
Credit lines start with a label and a colon, either : or the full-width
:. The labels are English (Lyrics by, Composed by,
Arranged by, Producer) or Chinese (作词, 作曲,
编曲, 制作人, and others).
Singer prefixes
A line can also start with a singer name and a colon:
[12765,752]The (12765,487)Weeknd:(13252,265) The prefix names the singer for this line and the lines after it, until a new prefix appears. It is not part of the lyrics.
Parsing
With @braccato/parsers (recommended)
@braccato/parsers reads the response as-is, envelope
included, and handles the cases above:
import { parseQRC } from "@braccato/parsers";
const response = await fetch(
"https://api.betterlyrics.org/qq/getLyrics?s=Wanderlust&a=The+Weeknd&al=Kiss+Land+(Deluxe)&d=307"
);
const { lyrics: qrc } = await response.json();
const lyrics = parseQRC(qrc, 307 * 1000, { title: "Wanderlust", artist: "The Weeknd" }); - Skips the header tags
- Drops credit lines
- With the
titleandartistmetadata, drops opening lines that only repeat them - Turns singer prefixes into an
agent(v1,v2, andv1000for a group) and removes them from the text - Adds instrumental lines for gaps longer than 5 seconds
Each line has startTimeMs, durationMs, words and a
parts array with one entry per word. QRCParser.parse(qrc, durationMs) is
the same without metadata, and detectParser selects QRCParser for a QQ
response.
Parsing by hand (JavaScript)
If you cannot add a dependency, this function extracts the lines and words. It does not remove credit lines or singer prefixes.
function parseQRC(xml) {
const match = xml.match(/LyricContent="([\s\S]*?)"\s*(?:\/?>|[a-zA-Z]+=)/);
const body = (match ? match[1] : xml)
.replace(/"/g, '"')
.replace(/&/g, "&");
const lines = [];
for (const raw of body.split("\n")) {
const line = raw.trim();
const header = line.match(/^\[(\d+),(\d+)\]/);
if (!header) continue;
const rest = line.slice(header[0].length);
const words = [];
let textStart = 0;
for (const token of rest.matchAll(/\((\d+),(\d+)\)/g)) {
words.push({
text: rest.slice(textStart, token.index),
startMs: Number(token[1]),
durationMs: Number(token[2]),
});
textStart = token.index + token[0].length;
}
lines.push({
startMs: Number(header[1]),
durationMs: Number(header[2]),
text: words.map((word) => word.text).join(""),
words,
});
}
return lines;
}
The header check ^\[(\d+),(\d+)\] only matches timed lines, so tags like
[ti:Wanderlust] are skipped. The word text is read as the span between tokens rather
than with a capture group, so a word that contains a bracket still parses.