// lyricResolve.cpp // // LRC parser + timed playback. See lyricResolve.h. #include "lyricResolve.h" #include #include #include #include #include #ifdef _WIN32 #include #else #include #include #endif namespace lr { namespace { struct TermSize { int cols = 0; int rows = 0; bool ok = false; }; // Small local copy of the terminal-size query so lyricResolve stays // independent of backgroundDisplay. TermSize terminalSize() { TermSize ts; #ifdef _WIN32 HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_SCREEN_BUFFER_INFO csbi; if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &csbi)) { ts.cols = csbi.srWindow.Right - csbi.srWindow.Left + 1; ts.rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1; ts.ok = (ts.cols > 0 && ts.rows > 0); } #else struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0) { ts.cols = static_cast(ws.ws_col); ts.rows = static_cast(ws.ws_row); ts.ok = true; } #endif return ts; } void writeRaw(const std::string& s) { fwrite(s.data(), 1, s.size(), stdout); fflush(stdout); } std::string ansiCursorTo(int row, int col) { return "\x1b[" + std::to_string(row) + ";" + std::to_string(col) + "H"; } // Parse a time tag (the text between brackets, e.g. "00:26.51" or "00:26") // into milliseconds. Returns false if it isn't a time tag. bool parseTimeTag(const std::string& tag, int64_t& outMs) { const size_t colon = tag.find(':'); if (colon == std::string::npos || colon == 0) return false; long mm = 0; for (size_t i = 0; i < colon; ++i) { if (tag[i] < '0' || tag[i] > '9') return false; mm = mm * 10 + (tag[i] - '0'); } size_t i = colon + 1; long ss = 0; while (i < tag.size() && tag[i] >= '0' && tag[i] <= '9') { ss = ss * 10 + (tag[i] - '0'); ++i; } if (i == colon + 1) return false; // no seconds digits long frac = 0; int fracDigits = 0; if (i < tag.size() && tag[i] == '.') { ++i; while (i < tag.size() && tag[i] >= '0' && tag[i] <= '9' && fracDigits < 3) { frac = frac * 10 + (tag[i] - '0'); ++fracDigits; ++i; } } if (i != tag.size()) return false; // trailing junk int64_t ms = mm * 60000 + ss * 1000; if (fracDigits == 1) ms += frac * 100; // .1 -> 100 ms else if (fracDigits == 2) ms += frac * 10; // .51 -> 510 ms else if (fracDigits == 3) ms += frac; // .123 -> 123 ms outMs = ms; return true; } // Byte length of the UTF-8 sequence starting at byte c (1 for ASCII/invalid). int utf8SeqLen(unsigned char c) { if (c < 0x80) return 1; if ((c & 0xE0) == 0xC0) return 2; if ((c & 0xF0) == 0xE0) return 3; if ((c & 0xF8) == 0xF0) return 4; return 1; } // Does this codepoint occupy two terminal columns (CJK / wide)? bool isWide(int cp) { return (cp >= 0x1100 && cp <= 0x11FF) || (cp >= 0x2E80 && cp <= 0xA4CF) || (cp >= 0xAC00 && cp <= 0xD7A3) || (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE30 && cp <= 0xFE4F) || (cp >= 0xFF00 && cp <= 0xFF60) || (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x20000 && cp <= 0x3FFFD); } // Truncate `text` so it is at most `maxWidth` terminal columns wide, never // splitting a UTF-8 character in the middle. Optionally reports the display // width of the returned string. std::string truncateToWidth(const std::string& text, int maxWidth, int* outWidth) { if (maxWidth <= 0) { if (outWidth) *outWidth = 0; return std::string(); } int width = 0; size_t i = 0; size_t lastBreak = 0; while (i < text.size()) { int len = utf8SeqLen(static_cast(text[i])); if (i + static_cast(len) > text.size()) len = 1; int cp = 0; const unsigned char lead = static_cast(text[i]); if (len == 1) { cp = lead; } else { if (len == 2) cp = (lead & 0x1F) << 6; else if (len == 3) cp = (lead & 0x0F) << 12; else cp = (lead & 0x07) << 18; for (int k = 1; k < len; ++k) { cp |= (static_cast(text[i + k]) & 0x3F) << (6 * (len - 1 - k)); } } const int w = isWide(cp) ? 2 : 1; if (width + w > maxWidth) break; width += w; lastBreak = i + static_cast(len); i += static_cast(len); } if (outWidth) *outWidth = width; return text.substr(0, lastBreak); } // Rewrite the current lyric at (row, col). When `maxWidth` is given the area // is cleared within exactly those columns (ECH), so the lyric band never // extends past the caller's chosen width (e.g. the video frame). Otherwise the // whole line is cleared/written. void refreshLyricLine(int row, int col, const std::string& text, int maxWidth) { std::string out; out += ansiCursorTo(row, col); if (maxWidth <= 0) { if (text.empty()) { out += "\x1b[2K"; // erase entire line } else { out += text; out += "\x1b[K"; // erase to end of line } } else { int width = 0; if (text.empty()) { out += "\x1b["; out += std::to_string(maxWidth); out += "X"; // erase the whole lyric band within the video width } else { out += truncateToWidth(text, maxWidth, &width); const int remain = maxWidth - width; if (remain > 0) { out += "\x1b["; out += std::to_string(remain); out += "X"; } } } writeRaw(out); } } // namespace LyricInfo parseLrc(const std::string& text) { LyricInfo info; size_t pos = 0; while (pos <= text.size()) { const size_t end = text.find('\n', pos); std::string line = text.substr(pos, end == std::string::npos ? std::string::npos : end - pos); pos = (end == std::string::npos) ? text.size() + 1 : end + 1; if (!line.empty() && line.back() == '\r') line.pop_back(); // CRLF tolerance // Collect all leading [tag] groups. std::vector tags; size_t idx = 0; while (idx < line.size() && line[idx] == '[') { const size_t close = line.find(']', idx); if (close == std::string::npos) break; tags.push_back(line.substr(idx + 1, close - idx - 1)); idx = close + 1; } if (tags.empty()) continue; const std::string lyricText = line.substr(idx); // may be empty for (const std::string& tag : tags) { int64_t ms = 0; if (parseTimeTag(tag, ms)) { info.lines.push_back({ms, lyricText}); } else if (tag.rfind("ti:", 0) == 0) { info.title = tag.substr(3); } else if (tag.rfind("ar:", 0) == 0) { info.artist = tag.substr(3); } else if (tag.rfind("al:", 0) == 0) { info.album = tag.substr(3); } else if (tag.rfind("by:", 0) == 0) { info.by = tag.substr(3); } else if (tag.rfind("offset:", 0) == 0) { try { info.offsetMs = std::stoi(tag.substr(7)); } catch (...) { info.offsetMs = 0; } } // Other tags are ignored. } } if (info.offsetMs != 0) { for (LyricLine& line : info.lines) { line.timeMs = std::max(0, line.timeMs + info.offsetMs); } } std::stable_sort(info.lines.begin(), info.lines.end(), [](const LyricLine& a, const LyricLine& b) { return a.timeMs < b.timeMs; }); info.valid = !info.lines.empty(); return info; } LyricInfo parseLrcFile(const std::string& path) { FILE* f = fopen(path.c_str(), "rb"); if (!f) return LyricInfo{}; std::string text; char buf[8192]; size_t n; while ((n = fread(buf, 1, sizeof buf, f)) > 0) text.append(buf, n); fclose(f); return parseLrc(text); } int playLyrics(const LyricInfo& lyric, const LyricDisplayOptions& opts, Timeline* timeline, std::atomic* stop) { if (!lyric.valid) return 1; Timeline localTimeline; if (!timeline) { timeline = &localTimeline; timeline->start(); } const TermSize ts = terminalSize(); const int row = (opts.row != 0) ? opts.row : (ts.ok ? ts.rows : 1); const int col = (opts.col > 0) ? opts.col : 1; const int maxWidth = (opts.maxWidth > 0) ? opts.maxWidth : (ts.ok ? std::max(1, ts.cols - 1) : 120); if (lyric.lines.empty()) return 0; // Hold the last line on screen a little while after it ends. const double endTime = lyric.lines.back().timeMs / 1000.0 + 5.0; int shown = -1; // index of the line currently on screen while (!(stop && stop->load())) { const double t = timeline->seconds(); // Active line = last line whose timestamp the timeline has reached. int idx = -1; for (size_t i = 0; i < lyric.lines.size(); ++i) { if (lyric.lines[i].timeMs / 1000.0 <= t) idx = static_cast(i); else break; } if (idx != shown) { shown = idx; const std::string& text = (idx >= 0) ? lyric.lines[idx].text : std::string(); if (opts.show) refreshLyricLine(row, col, text, maxWidth); } if (t >= endTime) break; std::this_thread::sleep_for(std::chrono::milliseconds(40)); } return 0; } int playLyricsFile(const std::string& path, const LyricDisplayOptions& opts, Timeline* timeline, std::atomic* stop) { const LyricInfo info = parseLrcFile(path); if (!info.valid) return 1; return playLyrics(info, opts, timeline, stop); } } // namespace lr