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 &quot; or &amp;. 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)
line2923331910
"Let "292333136
"it "29246997
"come "292566202
"to "293301377
"you"293678565

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 0 can 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 title and artist metadata, drops opening lines that only repeat them
  • Turns singer prefixes into an agent (v1, v2, and v1000 for 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(/&quot;/g, '"')
    .replace(/&amp;/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.