feat: add lyricResolve for resole .lrc file

feat: add backgroundDisplay for play ascii video in terminal
	feat: add musicPlayer for play music in terminal
This commit is contained in:
2026-08-30 11:35:47 +08:00
parent fdfbf5262f
commit 00b7c0b324
16 changed files with 97267 additions and 15 deletions
+510
View File
@@ -0,0 +1,510 @@
// backgroundDisplay.cpp
//
// Decodes a video file with FFmpeg and plays it in the terminal as truecolor
// ASCII art. See backgroundDisplay.h for the API and the adjustable/not-
// adjustable terminal sizing logic.
//
// Decoding runs on a background thread; frames are scaled straight to the
// final grid size and pushed into a small bounded queue that always keeps the
// newest frame (dropping old ones), so the producer never blocks. The calling
// thread paces rendering at the video's frame rate.
#include "backgroundDisplay.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
#include <io.h>
#include <windows.h>
#else
#include <sys/ioctl.h>
#include <unistd.h>
#endif
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
#include <libavutil/imgutils.h>
#include <libavutil/log.h>
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}
namespace bd {
// --- low-level terminal access --------------------------------------------
namespace {
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";
}
std::string ansiResize(int rows, int cols) {
return "\x1b[8;" + std::to_string(rows) + ";" + std::to_string(cols) + "t";
}
} // namespace
bool isTerminal() {
#ifdef _WIN32
return _isatty(_fileno(stdout)) != 0;
#else
return isatty(STDOUT_FILENO) != 0;
#endif
}
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;
}
bool resizeTerminal(int rows, int cols) {
if (!isTerminal()) return false;
const TermSize before = terminalSize();
if (!before.ok) return false;
writeRaw(ansiResize(rows, cols));
// Give the terminal emulator a moment to honour the request.
std::this_thread::sleep_for(std::chrono::milliseconds(150));
const TermSize after = terminalSize();
// Adjustable = the terminal actually changed size in response.
return after.ok && (after.rows != before.rows || after.cols != before.cols);
}
bool isTerminalAdjustable() {
if (!isTerminal()) return false;
const TermSize ts = terminalSize();
if (!ts.ok) return false;
// Probe with a different height than the current one, so any response is
// unambiguous, then restore the original size.
const int probeRows = ts.rows + 1;
writeRaw(ansiResize(probeRows, ts.cols));
std::this_thread::sleep_for(std::chrono::milliseconds(150));
const TermSize after = terminalSize();
const bool adjustable = after.ok && after.rows != ts.rows;
writeRaw(ansiResize(ts.rows, ts.cols));
return adjustable;
}
GridSize fitSizeForAspect(int videoW, int videoH, int maxCols, int maxRows) {
GridSize g;
if (videoW <= 0 || videoH <= 0 || maxCols <= 0 || maxRows <= 0) return g;
// Terminal cells are about twice as tall as they are wide, so a video of
// W x H needs roughly `cols * H / (2 * W)` rows.
int cols = maxCols;
int rows = static_cast<int>(std::lround(cols * static_cast<double>(videoH) /
(2.0 * videoW)));
if (rows > maxRows) {
rows = maxRows;
cols = static_cast<int>(std::floor(rows * 2.0 * videoW / videoH));
rows = std::min(rows, static_cast<int>(std::lround(
cols * static_cast<double>(videoH) / (2.0 * videoW))));
}
g.cols = std::max(1, cols);
g.rows = std::max(1, rows);
return g;
}
// --- decode pipeline -------------------------------------------------------
namespace {
// Luminance ramp, darkest -> brightest. Mirrors the reference asciivision
// project's palette.
const char PALETTE[] = " .'`^\",:;Il!i><~+_-?][}{1)(|\\tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
const size_t PALETTE_LEN = sizeof(PALETTE) - 1;
struct RgbFrame {
int width = 0;
int height = 0;
std::vector<uint8_t> rgb; // width * height * 3, tightly packed RGB24
};
// Bounded queue that always keeps the newest frame. pushLatest() never blocks
// (drops the oldest), so the producer can't deadlock against the consumer.
class BoundedQueue {
public:
explicit BoundedQueue(size_t capacity) : capacity_(capacity) {}
void pushLatest(RgbFrame frame) {
std::lock_guard<std::mutex> lock(mutex_);
while (queue_.size() >= capacity_) queue_.pop_front();
queue_.push_back(std::move(frame));
cv_.notify_one();
}
// Wait up to `deadline` for the newest frame. Returns false if none
// arrived by then. Drains older frames (only the newest is kept).
bool popLatestUntil(std::chrono::steady_clock::time_point deadline, RgbFrame& out) {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait_until(lock, deadline, [this] { return !queue_.empty() || finished_; });
if (queue_.empty()) return false;
out = std::move(queue_.back());
queue_.clear();
return true;
}
bool empty() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.empty();
}
void setFinished() {
std::lock_guard<std::mutex> lock(mutex_);
finished_ = true;
cv_.notify_all();
}
private:
mutable std::mutex mutex_;
std::condition_variable cv_;
std::deque<RgbFrame> queue_;
size_t capacity_;
bool finished_ = false;
};
struct DecodeContext {
AVFormatContext* fmt = nullptr;
int videoIndex = -1;
AVCodecContext* codec = nullptr;
int srcW = 0;
int srcH = 0;
AVPixelFormat srcPixFmt = AV_PIX_FMT_NONE;
SwsContext* sws = nullptr;
int dstW = 0;
int dstH = 0;
std::vector<uint8_t> dstBuf;
uint8_t* dstData[4] = {};
int dstLinesize[4] = {};
AVFrame* frame = nullptr;
AVPacket* pkt = nullptr;
bool loop = false;
BoundedQueue* queue = nullptr;
const std::atomic<bool>* stop = nullptr;
std::atomic<bool>* finished = nullptr;
};
void freeDecode(DecodeContext& dc) {
if (dc.pkt) av_packet_free(&dc.pkt);
if (dc.frame) av_frame_free(&dc.frame);
if (dc.codec) avcodec_free_context(&dc.codec);
if (dc.fmt) avformat_close_input(&dc.fmt);
if (dc.sws) sws_freeContext(dc.sws);
dc.pkt = nullptr;
dc.frame = nullptr;
dc.codec = nullptr;
dc.fmt = nullptr;
dc.sws = nullptr;
}
// Open the file, pick the best video stream and open its decoder. Outputs the
// source dimensions and the (approximate) frame rate. Returns 0 on success.
int probeVideo(const std::string& path, DecodeContext& dc, int& outW, int& outH,
double& outFps) {
// FFmpeg logs to stderr by default, which would corrupt the TUI.
av_log_set_level(AV_LOG_QUIET);
if (avformat_open_input(&dc.fmt, path.c_str(), nullptr, nullptr) < 0) return -1;
if (avformat_find_stream_info(dc.fmt, nullptr) < 0) return -1;
const int index = av_find_best_stream(dc.fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
if (index < 0) return -1;
dc.videoIndex = index;
AVStream* stream = dc.fmt->streams[index];
const AVCodec* codec = avcodec_find_decoder(stream->codecpar->codec_id);
if (!codec) return -1;
dc.codec = avcodec_alloc_context3(codec);
if (avcodec_parameters_to_context(dc.codec, stream->codecpar) < 0) return -1;
if (avcodec_open2(dc.codec, codec, nullptr) < 0) return -1;
dc.srcW = dc.codec->width;
dc.srcH = dc.codec->height;
dc.srcPixFmt = dc.codec->pix_fmt;
const AVRational rate = av_guess_frame_rate(dc.fmt, stream, nullptr);
double fps = (rate.num > 0 && rate.den > 0) ? static_cast<double>(rate.num) / rate.den
: 0.0;
if (fps <= 0.0 || fps > 120.0) fps = 24.0;
outW = dc.srcW;
outH = dc.srcH;
outFps = fps;
return 0;
}
int setupScaler(DecodeContext& dc, int dstW, int dstH) {
dc.dstW = dstW;
dc.dstH = dstH;
dc.sws = sws_getCachedContext(nullptr, dc.srcW, dc.srcH, dc.srcPixFmt, dstW, dstH,
AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr,
nullptr);
if (!dc.sws) return -1;
// Tightly packed RGB24 (align = 1), so row y starts at y * width * 3.
dc.dstBuf.resize(static_cast<size_t>(dstW) * dstH * 3);
if (av_image_fill_arrays(dc.dstData, dc.dstLinesize, dc.dstBuf.data(),
AV_PIX_FMT_RGB24, dstW, dstH, 1) < 0) {
return -1;
}
dc.frame = av_frame_alloc();
dc.pkt = av_packet_alloc();
return (dc.frame && dc.pkt) ? 0 : -1;
}
void scaleAndPush(DecodeContext& dc) {
sws_scale(dc.sws, dc.frame->data, dc.frame->linesize, 0, dc.frame->height,
dc.dstData, dc.dstLinesize);
RgbFrame frame;
frame.width = dc.dstW;
frame.height = dc.dstH;
frame.rgb.assign(dc.dstBuf.begin(), dc.dstBuf.end());
dc.queue->pushLatest(std::move(frame));
}
void decoderThreadFn(DecodeContext& dc) {
while (!(dc.stop && dc.stop->load())) {
const int r = av_read_frame(dc.fmt, dc.pkt);
if (r == AVERROR_EOF) {
// Flush whatever is buffered in the decoder.
avcodec_send_packet(dc.codec, nullptr);
while (avcodec_receive_frame(dc.codec, dc.frame) == 0) scaleAndPush(dc);
if (dc.loop && !(dc.stop && dc.stop->load())) {
av_seek_frame(dc.fmt, -1, 0, AVSEEK_FLAG_BACKWARD);
avcodec_flush_buffers(dc.codec);
continue;
}
break;
}
if (r < 0) break; // non-EOF read error
if (dc.pkt->stream_index == dc.videoIndex) {
avcodec_send_packet(dc.codec, dc.pkt);
while (avcodec_receive_frame(dc.codec, dc.frame) == 0) scaleAndPush(dc);
}
av_packet_unref(dc.pkt);
}
if (dc.finished) dc.finished->store(true);
dc.queue->setFinished();
}
void appendColor(std::string& out, bool background, uint8_t r, uint8_t g, uint8_t b) {
char buf[32];
const int n = std::snprintf(buf, sizeof buf, "\x1b[%d;2;%d;%d;%dm",
background ? 48 : 38, r, g, b);
out.append(buf, static_cast<size_t>(n));
}
char asciiFor(uint8_t r, uint8_t g, uint8_t b) {
const double y = 0.299 * r + 0.587 * g + 0.114 * b;
const size_t index = (static_cast<size_t>(y) * (PALETTE_LEN - 1)) / 255;
return PALETTE[index];
}
// Render one RGB frame into `out` as ANSI truecolor cells. The frame is drawn
// at (offsetX, offsetY) (1-based row semantics handled by the caller's cursor
// positioning); when `showLabel` is set the source video size (srcW x srcH)
// is printed on its own line at the top of the terminal first.
void renderRgbFrame(const RgbFrame& frame, float intensity, int offsetX, int offsetY,
bool showLabel, int srcW, int srcH, std::string& out) {
out.clear();
out += ansiCursorTo(1, 1);
if (showLabel) {
out += "\x1b[0m";
out += std::to_string(srcW);
out += 'x';
out += std::to_string(srcH);
out += "\x1b[K"; // erase the rest of the label row
}
out += ansiCursorTo(offsetY + 1, 1);
const uint8_t* base = frame.rgb.data();
const int cols = frame.width;
const int rows = frame.height;
for (int y = 0; y < rows; ++y) {
if (y > 0) out += "\r\n";
if (offsetX > 0) out.append(static_cast<size_t>(offsetX), ' ');
const uint8_t* row = base + static_cast<size_t>(y) * cols * 3;
const float scanline = (y % 2 == 0) ? 0.84f : 1.0f;
const float factor = std::clamp(intensity * scanline, 0.1f, 1.2f);
int lastFg[3] = {-1, -1, -1};
int lastBg[3] = {-1, -1, -1};
for (int x = 0; x < cols; ++x) {
const size_t i = static_cast<size_t>(x) * 3;
const uint8_t r = row[i], g = row[i + 1], b = row[i + 2];
const int fr = static_cast<int>(r * factor);
const int fg = static_cast<int>(g * factor);
const int fb = static_cast<int>(b * factor);
if (fr != lastFg[0] || fg != lastFg[1] || fb != lastFg[2]) {
appendColor(out, false, static_cast<uint8_t>(fr), static_cast<uint8_t>(fg),
static_cast<uint8_t>(fb));
lastFg[0] = fr;
lastFg[1] = fg;
lastFg[2] = fb;
}
const int br = static_cast<int>(r * factor * 0.16f);
const int bg = static_cast<int>(g * factor * 0.16f);
const int bb = static_cast<int>(b * factor * 0.16f);
if (br != lastBg[0] || bg != lastBg[1] || bb != lastBg[2]) {
appendColor(out, true, static_cast<uint8_t>(br), static_cast<uint8_t>(bg),
static_cast<uint8_t>(bb));
lastBg[0] = br;
lastBg[1] = bg;
lastBg[2] = bb;
}
out += asciiFor(r, g, b);
}
}
}
} // namespace
// --- playback --------------------------------------------------------------
int playVideo(const std::string& path, const PlayVideoOptions& opts) {
if (!isTerminal()) return 2;
DecodeContext dc;
int videoW = 0, videoH = 0;
double fps = 24.0;
if (probeVideo(path, dc, videoW, videoH, fps) < 0) {
freeDecode(dc);
return 1;
}
TermSize ts = terminalSize();
if (!ts.ok) {
freeDecode(dc);
return 2;
}
// Decide the ASCII grid size and whether to print the frame-size label.
int gridW = 0, gridH = 0;
int offsetX = 0, offsetY = 0;
bool showLabel = false;
const int bottomRows = opts.bottomRows > 0 ? opts.bottomRows : 0;
bool adjustable = false;
if (opts.tryResize) {
int aspectRows = static_cast<int>(std::lround(
ts.cols * static_cast<double>(videoH) / (2.0 * videoW)));
aspectRows = std::clamp(aspectRows, 2, 200);
adjustable = resizeTerminal(aspectRows + bottomRows, ts.cols);
if (adjustable) {
// The terminal changed; use whatever it actually became.
ts = terminalSize();
}
}
if (adjustable) {
gridW = ts.cols;
gridH = std::max(1, ts.rows - bottomRows);
} else {
// Not adjustable (or resize disabled): fit proportionally into the
// current size. The frame-size label gets its own line at the top, so
// reserve it before fitting; bottom rows are kept free for other
// content (e.g. the lyric line).
const int labelRows = 1;
const int maxRows = std::max(1, ts.rows - bottomRows - labelRows);
GridSize g = fitSizeForAspect(videoW, videoH, ts.cols, maxRows);
gridW = g.cols;
gridH = g.rows;
showLabel = true; // print "WxH" on its own line at the top
offsetX = (ts.cols - gridW) / 2;
offsetY = 1 + (maxRows - gridH) / 2;
}
if (gridW <= 0 || gridH <= 0 || setupScaler(dc, gridW, gridH) < 0) {
freeDecode(dc);
return 1;
}
// Hide the cursor and clear the screen.
writeRaw("\x1b[?25l\x1b[2J\x1b[H");
std::atomic<bool> finished(false);
BoundedQueue queue(4);
dc.queue = &queue;
dc.stop = opts.stop;
dc.finished = &finished;
dc.loop = opts.loop;
std::thread decoder(decoderThreadFn, std::ref(dc));
const auto interval =
std::chrono::microseconds(static_cast<int64_t>(1'000'000.0 / fps));
auto next = std::chrono::steady_clock::now();
std::string out;
out.reserve(static_cast<size_t>(gridW) * gridH * 24);
while (!(opts.stop && opts.stop->load())) {
RgbFrame frame;
if (queue.popLatestUntil(next, frame)) {
renderRgbFrame(frame, opts.intensity, offsetX, offsetY, showLabel, videoW,
videoH, out);
writeRaw(out);
}
if (finished.load() && queue.empty()) break;
next += interval;
const auto now = std::chrono::steady_clock::now();
if (next <= now) next = now + interval; // fell behind: re-anchor
std::this_thread::sleep_until(next);
}
queue.setFinished();
decoder.join();
writeRaw("\x1b[0m\x1b[?25h");
freeDecode(dc);
return 0;
}
} // namespace bd
+76
View File
@@ -0,0 +1,76 @@
// backgroundDisplay.h
//
// Decode a video file (MP4 or any FFmpeg-supported container/codec) and play
// it in the terminal as truecolor ASCII art, honouring the video's aspect
// ratio.
//
// Terminal behaviour:
// - If the terminal can be resized programmatically ("adjustable"), the
// current width is kept and the height is set so the ASCII grid matches
// the video aspect ratio (terminal cells are roughly 2:1 tall:wide).
// - Otherwise the video is letterboxed to the largest size that fits the
// current terminal and the video frame size (e.g. "1280x720") is printed
// in the top-left corner of the terminal.
//
// This module is self-contained (raw ANSI escape sequences, no ncurses).
// Decoding runs on a background thread; playVideo() blocks the calling
// thread for the duration of playback, so callers can run several show
// components concurrently by using their own std::thread.
#pragma once
#include <string>
#include <atomic>
namespace bd {
struct TermSize {
int cols = 0; // character columns
int rows = 0; // character rows
bool ok = false; // false when stdout is not a tty or the size is unknown
};
struct GridSize {
int cols = 0;
int rows = 0;
};
// --- terminal helpers -----------------------------------------------------
// True if stdout is an interactive terminal (isatty).
bool isTerminal();
// Current terminal size in character cells.
TermSize terminalSize();
// Ask the terminal to resize to (rows x cols) via the xterm sequence
// "\x1b[8;rows;colst" and verify it actually took effect (TIOCGWINSZ).
bool resizeTerminal(int rows, int cols);
// Probe whether the terminal honours programmatic resize requests. Restores
// the original size afterwards.
bool isTerminalAdjustable();
// Largest whole grid (cols x rows) with the video aspect ratio that fits
// inside (maxCols x maxRows). Rows are counted with 2:1 cell aspect.
GridSize fitSizeForAspect(int videoW, int videoH, int maxCols, int maxRows);
// --- video playback -------------------------------------------------------
struct PlayVideoOptions {
std::string path;
bool loop = false; // repeat the video until stop
float intensity = 1.0f; // brightness multiplier for fg/bg
bool tryResize = true; // false => force the "not adjustable" fit path
int bottomRows = 0; // leave this many rows at the bottom for other content
std::atomic<bool>* stop = nullptr; // set to true to end playback early
};
// Play `path` as ASCII art. Blocks until the video ends (or `stop` is set).
// Returns:
// 0 playback finished (or stopped) cleanly
// 1 failed to open/decode the video
// 2 stdout is not a tty (nothing was written)
int playVideo(const std::string& path, const PlayVideoOptions& opts = {});
} // namespace bd
+274
View File
@@ -0,0 +1,274 @@
// 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
+54
View File
@@ -0,0 +1,54 @@
// lyricResolve.h
//
// Parse .lrc lyric files and play them back on a timeline in the terminal.
//
// playLyrics()/playLyricsFile() anchor their timeline at the moment they are
// called (t = 0) and print each lyric line at its timestamp. The module is
// self-contained and multibyte-safe: Japanese/Chinese UTF-8 lines are printed
// whole and only ever truncated on a character boundary.
#pragma once
#include <atomic>
#include <cstdint>
#include <string>
#include <vector>
namespace lr {
struct LyricLine {
int64_t timeMs = 0; // from the start of the track
std::string text; // empty => clear the display
};
struct LyricInfo {
std::vector<LyricLine> lines; // sorted ascending by timeMs (stable)
std::string title, artist, album, by;
int offsetMs = 0;
bool valid = false; // false if nothing was parsed
};
// Parse LRC text: "[mm:ss.xx]text", multiple timestamps per line, metadata
// tags ([ti:]/[ar:]/[al:]/[by:]/[offset:]) and timestamp-only lines (empty
// text, which clears the display). The [offset:] value is applied to all lines.
LyricInfo parseLrc(const std::string& text);
// Read a file and parse it. Returns a LyricInfo with valid == false on error.
LyricInfo parseLrcFile(const std::string& path);
struct LyricDisplayOptions {
int row = 0; // 1-based terminal row; 0 = auto (last terminal row)
bool show = true; // false => run the timeline silently (no terminal writes)
};
// Play the lyrics on a timeline starting at t = 0. Blocks until every line has
// been shown (or `stop` is set). Returns 0.
int playLyrics(const LyricInfo& lyric,
const LyricDisplayOptions& opts = {},
std::atomic<bool>* stop = nullptr);
int playLyricsFile(const std::string& path,
const LyricDisplayOptions& opts = {},
std::atomic<bool>* stop = nullptr);
} // namespace lr
+112
View File
@@ -0,0 +1,112 @@
// musicPlayer.cpp
//
// miniaudio-backed playback of mp3 / wav / flac files. See musicPlayer.h.
//
// MINIAUDIO_IMPLEMENTATION is defined in this one translation unit so the
// large miniaudio implementation is compiled exactly once per binary.
#define MINIAUDIO_IMPLEMENTATION
#include "miniaudio.h"
#include "musicPlayer.h"
#include <atomic>
#include <cstring>
#include <string>
// When built with ALSA support, silence ALSA's own error printing: with no
// sound card present it dumps a lot of "cannot find card '0'" chatter to
// stderr, which would corrupt the TUI during playback.
#ifdef HMS_HAS_ALSA
#include <alsa/asoundlib.h>
#endif
namespace mp {
namespace {
#ifdef HMS_HAS_ALSA
void alsaErrorHandler(const char*, int, const char*, int, const char*, ...) {
// swallow ALSA's verbose configuration/device errors
}
#endif
struct PlaybackUser {
ma_decoder* decoder;
const std::atomic<bool>* stop;
std::atomic<bool> ended{false};
};
void dataCallback(ma_device* device, void* out, const void* /*in*/,
ma_uint32 frameCount) {
auto* user = static_cast<PlaybackUser*>(device->pUserData);
ma_decoder* decoder = user->decoder;
const size_t bytesPerFrame =
ma_get_bytes_per_frame(decoder->outputFormat, decoder->outputChannels);
if (user->stop && user->stop->load()) {
// Stop requested: feed silence and let the main loop tear the device
// down.
memset(out, 0, static_cast<size_t>(frameCount) * bytesPerFrame);
user->ended.store(true);
return;
}
ma_uint64 framesRead = 0;
const ma_result result =
ma_decoder_read_pcm_frames(decoder, out, frameCount, &framesRead);
if (framesRead > 0 && framesRead < frameCount) {
// Underrun / near EOF: zero-pad the rest of the buffer.
memset(static_cast<char*>(out) + framesRead * bytesPerFrame, 0,
static_cast<size_t>(frameCount - framesRead) * bytesPerFrame);
} else if (framesRead == 0) {
memset(out, 0, static_cast<size_t>(frameCount) * bytesPerFrame);
}
// A short read means the file is exhausted (MA_AT_END); any other
// non-success result is a decode error. Both end the playback loop.
if (framesRead < frameCount || (result != MA_SUCCESS && result != MA_AT_END)) {
user->ended.store(true);
}
}
} // namespace
int play(const std::string& path, std::atomic<bool>* stop) {
if (path.empty()) return 1;
ma_decoder_config decoderConfig = ma_decoder_config_init_default();
ma_decoder decoder;
if (ma_decoder_init_file(path.c_str(), &decoderConfig, &decoder) != MA_SUCCESS) {
return 2; // cannot open / no decoder for the format
}
#ifdef HMS_HAS_ALSA
snd_lib_error_set_handler(alsaErrorHandler);
#endif
ma_device_config deviceConfig = ma_device_config_init(ma_device_type_playback);
deviceConfig.playback.format = decoder.outputFormat;
deviceConfig.playback.channels = decoder.outputChannels;
deviceConfig.sampleRate = decoder.outputSampleRate;
deviceConfig.dataCallback = dataCallback;
PlaybackUser user{&decoder, stop};
deviceConfig.pUserData = &user;
ma_device device;
if (ma_device_init(nullptr, &deviceConfig, &device) != MA_SUCCESS) {
ma_decoder_uninit(&decoder);
return 3; // no audio backend / device available
}
ma_device_start(&device);
while (!user.ended.load() && !(stop && stop->load())) {
ma_sleep(50);
}
ma_device_uninit(&device);
ma_decoder_uninit(&decoder);
return 0;
}
} // namespace mp
+22
View File
@@ -0,0 +1,22 @@
// musicPlayer.h
//
// Play an audio file (MP3 / WAV / FLAC) through the system audio device.
// Backed by the vendored miniaudio library (third_party/miniaudio.h), which
// decodes and plays these formats natively and works on both Linux and
// Windows. The header deliberately does not include miniaudio.
#pragma once
#include <atomic>
#include <string>
namespace mp {
// Blocking playback of `path`. Returns 0 on success (played to the end or
// stopped early via `stop`). Non-zero return values:
// 1 argument error
// 2 could not open/decode the file
// 3 no audio device available (caller should degrade gracefully)
int play(const std::string& path, std::atomic<bool>* stop = nullptr);
} // namespace mp