Files
Hatsune-Miku-s-Shoshitsu-in…/modules/lyricResolve.cpp
T
NanamiAdmin 00b7c0b324 feat: add lyricResolve for resole .lrc file
feat: add backgroundDisplay for play ascii video in terminal
	feat: add musicPlayer for play music in terminal
2026-08-30 11:35:47 +08:00

275 lines
8.7 KiB
C++

// lyricResolve.cpp
//
// LRC parser + timed playback. See lyricResolve.h.
#include "lyricResolve.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <thread>
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/ioctl.h>
#include <unistd.h>
#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<int>(ws.ws_col);
ts.rows = static_cast<int>(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.
std::string truncateToWidth(const std::string& text, int maxWidth) {
if (maxWidth <= 0) return std::string();
int width = 0;
size_t i = 0;
size_t lastBreak = 0;
while (i < text.size()) {
int len = utf8SeqLen(static_cast<unsigned char>(text[i]));
if (i + static_cast<size_t>(len) > text.size()) len = 1;
int cp = 0;
const unsigned char lead = static_cast<unsigned char>(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<unsigned char>(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<size_t>(len);
i += static_cast<size_t>(len);
}
return text.substr(0, lastBreak);
}
// Rewrite the current lyric on its row: clear the line, or write the text and
// erase to the end of the line.
void refreshLyricLine(int row, const std::string& text, int maxWidth) {
std::string out;
out += ansiCursorTo(row, 1);
if (text.empty()) {
out += "\x1b[2K"; // erase entire line
} else {
out += truncateToWidth(text, maxWidth);
out += "\x1b[K"; // erase to end of line
}
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<std::string> 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<int64_t>(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,
std::atomic<bool>* stop) {
if (!lyric.valid) return 1;
const TermSize ts = terminalSize();
const int row = (opts.row != 0) ? opts.row : (ts.ok ? ts.rows : 1);
const int maxWidth = ts.ok ? std::max(1, ts.cols - 1) : 120;
const auto start = std::chrono::steady_clock::now();
for (const LyricLine& line : lyric.lines) {
if (stop && stop->load()) break;
// Sleep in small steps so a stop request is honoured promptly even
// when the next lyric line is far in the future.
const auto target = start + std::chrono::milliseconds(line.timeMs);
while (std::chrono::steady_clock::now() < target) {
if (stop && stop->load()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
if (stop && stop->load()) break;
if (opts.show) refreshLyricLine(row, line.text, maxWidth);
}
return 0;
}
int playLyricsFile(const std::string& path, const LyricDisplayOptions& opts,
std::atomic<bool>* stop) {
const LyricInfo info = parseLrcFile(path);
if (!info.valid) return 1;
return playLyrics(info, opts, stop);
}
} // namespace lr