diff --git a/modules/backgroundDisplay.cpp b/modules/backgroundDisplay.cpp index 4b475b7..d070c63 100644 --- a/modules/backgroundDisplay.cpp +++ b/modules/backgroundDisplay.cpp @@ -410,9 +410,17 @@ void renderRgbFrame(const RgbFrame& frame, float intensity, int offsetX, int off // --- playback -------------------------------------------------------------- -int playVideo(const std::string& path, const PlayVideoOptions& opts) { +int playVideo(const std::string& path, Timeline* timeline, + const PlayVideoOptions& opts) { if (!isTerminal()) return 2; + // Fallback clock for callers that don't provide one. + Timeline localTimeline; + if (!timeline) { + timeline = &localTimeline; + timeline->start(); + } + DecodeContext dc; int videoW = 0, videoH = 0; double fps = 24.0; @@ -431,6 +439,7 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) { int gridW = 0, gridH = 0; int offsetX = 0, offsetY = 0; bool showLabel = false; + const int topRows = opts.topRows > 0 ? opts.topRows : 0; const int bottomRows = opts.bottomRows > 0 ? opts.bottomRows : 0; bool adjustable = false; @@ -438,7 +447,7 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) { int aspectRows = static_cast(std::lround( ts.cols * static_cast(videoH) / (2.0 * videoW))); aspectRows = std::clamp(aspectRows, 2, 200); - adjustable = resizeTerminal(aspectRows + bottomRows, ts.cols); + adjustable = resizeTerminal(aspectRows + topRows + bottomRows, ts.cols); if (adjustable) { // The terminal changed; use whatever it actually became. ts = terminalSize(); @@ -447,22 +456,30 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) { if (adjustable) { gridW = ts.cols; - gridH = std::max(1, ts.rows - bottomRows); + gridH = std::max(1, ts.rows - topRows - bottomRows); + offsetY = topRows; } 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); + // current size. When the top is reserved for other content (lyrics), + // the frame-size label is suppressed; otherwise it gets its own line + // at the top. + showLabel = (topRows == 0); + const int labelRows = showLabel ? 1 : 0; + const int maxRows = std::max(1, ts.rows - topRows - 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; + offsetY = topRows + (maxRows - gridH) / 2; } + if (opts.outGrid) { + opts.outGrid->cols = gridW; + opts.outGrid->rows = gridH; + } + if (opts.outOffsetX) *opts.outOffsetX = offsetX; + if (opts.gridReady) opts.gridReady->store(true); + if (gridW <= 0 || gridH <= 0 || setupScaler(dc, gridW, gridH) < 0) { freeDecode(dc); return 1; @@ -479,25 +496,38 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) { dc.loop = opts.loop; std::thread decoder(decoderThreadFn, std::ref(dc)); - const auto interval = - std::chrono::microseconds(static_cast(1'000'000.0 / fps)); - auto next = std::chrono::steady_clock::now(); + const double intervalSec = 1.0 / fps; std::string out; out.reserve(static_cast(gridW) * gridH * 24); + // Render one (latest) frame per time slot, paced by the shared timeline. + // `nextSlot` jumps past any missed slots, so a slow decode never causes a + // catch-up burst of redraws. + double nextSlot = 0.0; while (!(opts.stop && opts.stop->load())) { RgbFrame frame; - if (queue.popLatestUntil(next, frame)) { + const auto deadline = + timeline->startTime() + + std::chrono::duration_cast( + std::chrono::duration(nextSlot)); + const bool got = queue.popLatestUntil(deadline, frame); + + // Wait until the timeline reaches this slot (unless stopped). + while (timeline->seconds() < nextSlot) { + if (opts.stop && opts.stop->load()) break; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + if (opts.stop && opts.stop->load()) break; + + if (got) { 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); + const double now = timeline->seconds(); + nextSlot = std::max(nextSlot + intervalSec, now + intervalSec); } queue.setFinished(); diff --git a/modules/backgroundDisplay.h b/modules/backgroundDisplay.h index f61b46f..38ef5b2 100644 --- a/modules/backgroundDisplay.h +++ b/modules/backgroundDisplay.h @@ -22,6 +22,8 @@ #include #include +#include "timeline.h" + namespace bd { struct TermSize { @@ -62,15 +64,21 @@ struct PlayVideoOptions { 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 + int topRows = 0; // reserve this many rows ABOVE the video (e.g. lyrics) + int bottomRows = 0; // reserve this many rows BELOW the video + GridSize* outGrid = nullptr; // receives the computed video grid (optional) + int* outOffsetX = nullptr; // receives the video's horizontal offset in cols + std::atomic* gridReady = nullptr; // set true once the layout is reported std::atomic* stop = nullptr; // set to true to end playback early }; -// Play `path` as ASCII art. Blocks until the video ends (or `stop` is set). +// Play `path` as ASCII art, paced by `timeline` (may be null for an +// independent local clock). 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 = {}); +int playVideo(const std::string& path, Timeline* timeline, + const PlayVideoOptions& opts = {}); } // namespace bd diff --git a/modules/lyricResolve.cpp b/modules/lyricResolve.cpp index 70f3e94..a43a9e6 100644 --- a/modules/lyricResolve.cpp +++ b/modules/lyricResolve.cpp @@ -121,9 +121,13 @@ bool isWide(int cp) { } // 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(); +// 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; @@ -151,19 +155,39 @@ std::string truncateToWidth(const std::string& text, int maxWidth) { lastBreak = i + static_cast(len); i += static_cast(len); } + if (outWidth) *outWidth = width; 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) { +// 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, 1); - if (text.empty()) { - out += "\x1b[2K"; // erase entire line + 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 { - out += truncateToWidth(text, maxWidth); - out += "\x1b[K"; // erase to end of line + 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); } @@ -241,34 +265,52 @@ LyricInfo parseLrcFile(const std::string& path) { } int playLyrics(const LyricInfo& lyric, const LyricDisplayOptions& opts, - std::atomic* stop) { + 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 maxWidth = ts.ok ? std::max(1, ts.cols - 1) : 120; + 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); - 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 (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 (stop && stop->load()) break; - if (opts.show) refreshLyricLine(row, line.text, maxWidth); + 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, - std::atomic* stop) { + Timeline* timeline, std::atomic* stop) { const LyricInfo info = parseLrcFile(path); if (!info.valid) return 1; - return playLyrics(info, opts, stop); + return playLyrics(info, opts, timeline, stop); } } // namespace lr diff --git a/modules/lyricResolve.h b/modules/lyricResolve.h index fc74203..cf87446 100644 --- a/modules/lyricResolve.h +++ b/modules/lyricResolve.h @@ -14,6 +14,8 @@ #include #include +#include "timeline.h" + namespace lr { struct LyricLine { @@ -38,17 +40,23 @@ LyricInfo parseLrcFile(const std::string& path); struct LyricDisplayOptions { int row = 0; // 1-based terminal row; 0 = auto (last terminal row) + int col = 0; // 1-based start column; 0 = column 1 + int maxWidth = 0; // max display columns; 0 = terminal width - 1 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. +// Play the lyrics against `timeline` (may be null for a local clock): the +// current lyric line is displayed in real time as the timeline advances. +// Blocks until the timeline passes the last line (or `stop` is set). +// Returns 0. int playLyrics(const LyricInfo& lyric, const LyricDisplayOptions& opts = {}, + Timeline* timeline = nullptr, std::atomic* stop = nullptr); int playLyricsFile(const std::string& path, const LyricDisplayOptions& opts = {}, + Timeline* timeline = nullptr, std::atomic* stop = nullptr); } // namespace lr diff --git a/modules/musicPlayer.cpp b/modules/musicPlayer.cpp index b2750f3..7a66cbf 100644 --- a/modules/musicPlayer.cpp +++ b/modules/musicPlayer.cpp @@ -73,9 +73,13 @@ void dataCallback(ma_device* device, void* out, const void* /*in*/, } // namespace -int play(const std::string& path, std::atomic* stop) { +int play(const std::string& path, Timeline* timeline, std::atomic* stop) { if (path.empty()) return 1; + // Audio is the show's clock: anchor the timeline the moment playback is + // about to begin (a no-op if the caller already anchored it). + if (timeline && !timeline->started()) timeline->start(); + ma_decoder_config decoderConfig = ma_decoder_config_init_default(); ma_decoder decoder; if (ma_decoder_init_file(path.c_str(), &decoderConfig, &decoder) != MA_SUCCESS) { diff --git a/modules/musicPlayer.h b/modules/musicPlayer.h index 07ee49a..47ad4e0 100644 --- a/modules/musicPlayer.h +++ b/modules/musicPlayer.h @@ -10,13 +10,18 @@ #include #include +#include "timeline.h" + namespace mp { -// Blocking playback of `path`. Returns 0 on success (played to the end or -// stopped early via `stop`). Non-zero return values: +// Blocking playback of `path` in real time. `timeline` is optional; if given +// and not yet anchored, the music anchors it (audio is the show's clock). +// 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* stop = nullptr); +int play(const std::string& path, Timeline* timeline = nullptr, + std::atomic* stop = nullptr); } // namespace mp diff --git a/modules/timeline.h b/modules/timeline.h new file mode 100644 index 0000000..7df7e80 --- /dev/null +++ b/modules/timeline.h @@ -0,0 +1,50 @@ +// timeline.h +// +// Shared playback clock. The show's modules (video / music / lyrics) all +// receive a Timeline* so they time their output against the same anchor: +// the video paces its frames from Timeline::startTime(), the lyrics pick the +// line for Timeline::seconds(), and the audio simply plays in real time. +// +// The timeline is anchored once (idempotently) before the show starts. + +#pragma once + +#include +#include + +class Timeline { +public: + // Anchor the timeline to now. Safe to call more than once; an already + // anchored timeline is not re-anchored by the modules. + void start() { + std::lock_guard lock(mutex_); + if (started_) return; + start_ = std::chrono::steady_clock::now(); + started_ = true; + } + + // Whether the timeline has been anchored yet. + bool started() const { + std::lock_guard lock(mutex_); + return started_; + } + + // Seconds elapsed since the anchor (0 until start()). + double seconds() const { + std::lock_guard lock(mutex_); + if (!started_) return 0.0; + return std::chrono::duration(std::chrono::steady_clock::now() - start_) + .count(); + } + + // Wall-clock instant the timeline was anchored, for deadline math. + std::chrono::steady_clock::time_point startTime() const { + std::lock_guard lock(mutex_); + return start_; + } + +private: + mutable std::mutex mutex_; + std::chrono::steady_clock::time_point start_{}; + bool started_ = false; +}; diff --git a/res/video.mp4 b/res/video.mp4 new file mode 100644 index 0000000..eb23fa9 Binary files /dev/null and b/res/video.mp4 differ diff --git a/tools/demo.cpp b/tools/demo.cpp index 4135321..f8d3d26 100644 --- a/tools/demo.cpp +++ b/tools/demo.cpp @@ -10,6 +10,7 @@ #include "backgroundDisplay.h" #include "lyricResolve.h" #include "musicPlayer.h" +#include "timeline.h" #include #include @@ -42,8 +43,9 @@ static void usage(const char* argv0) { argv0, HMS_DEMO_VIDEO, HMS_DEMO_MUSIC, HMS_DEMO_LYRICS); } -static void playMusicQuietly(const std::string& path, std::atomic* stop) { - const int rc = mp::play(path, stop); +static void playMusicQuietly(const std::string& path, Timeline* timeline, + std::atomic* stop) { + const int rc = mp::play(path, timeline, stop); if (rc == 2) { std::fprintf(stderr, "demo: cannot open audio file '%s'\n", path.c_str()); } else if (rc == 3) { @@ -86,6 +88,8 @@ int main(int argc, char** argv) { } std::atomic stop{false}; + Timeline timeline; + timeline.start(); // Optional hard deadline (e.g. for quick verification). std::thread deadlineThread; @@ -99,8 +103,8 @@ int main(int argc, char** argv) { if (!useVideo) { // No video: just music + lyrics. std::thread musicThread, lyricThread; - if (useMusic) musicThread = std::thread([&] { playMusicQuietly(music, &stop); }); - if (useLyrics) lyricThread = std::thread([&] { lr::playLyricsFile(lyrics, {}, &stop); }); + if (useMusic) musicThread = std::thread([&] { playMusicQuietly(music, &timeline, &stop); }); + if (useLyrics) lyricThread = std::thread([&] { lr::playLyricsFile(lyrics, {}, &timeline, &stop); }); if (musicThread.joinable()) musicThread.join(); if (lyricThread.joinable()) lyricThread.join(); } else if (!useMusic && !useLyrics) { @@ -110,7 +114,7 @@ int main(int argc, char** argv) { vo.loop = false; vo.tryResize = tryResize; vo.stop = &stop; - bd::playVideo(video, vo); + bd::playVideo(video, &timeline, vo); } else { // Full show: video loops in the background, music + lyrics on top. // Reserve the bottom row for the lyric line. @@ -121,7 +125,7 @@ int main(int argc, char** argv) { vo.tryResize = tryResize; vo.bottomRows = useLyrics ? 1 : 0; vo.stop = &stop; - bd::playVideo(video, vo); + bd::playVideo(video, &timeline, vo); }); // Give the video a moment to detect and adjust the terminal size so @@ -129,8 +133,8 @@ int main(int argc, char** argv) { std::this_thread::sleep_for(std::chrono::milliseconds(400)); std::thread musicThread, lyricThread; - if (useMusic) musicThread = std::thread([&] { playMusicQuietly(music, &stop); }); - if (useLyrics) lyricThread = std::thread([&] { lr::playLyricsFile(lyrics, {}, &stop); }); + if (useMusic) musicThread = std::thread([&] { playMusicQuietly(music, &timeline, &stop); }); + if (useLyrics) lyricThread = std::thread([&] { lr::playLyricsFile(lyrics, {}, &timeline, &stop); }); if (musicThread.joinable()) musicThread.join(); if (lyricThread.joinable()) lyricThread.join(); diff --git a/uninstall.cpp b/uninstall.cpp index 45b837e..751bb5b 100644 --- a/uninstall.cpp +++ b/uninstall.cpp @@ -1,7 +1,146 @@ -#include +// Hatsune Miku's Shoshitsu in Terminal +// +// The uninstall hook program: plays a short "show" in the terminal — an ASCII +// video background at 60% brightness, a music track, and timed lyrics shown +// above the video — all synchronised to one shared timeline. +// +// Compiles and runs on Windows (MSYS2/MinGW) as well as Linux. -int main() -{ - +#include "backgroundDisplay.h" +#include "lyricResolve.h" +#include "musicPlayer.h" +#include "timeline.h" + +#include +#include +#include +#include +#include + +// ---- global configuration -------------------------------------------------- + +// Debug mode toggles how the show's resources are selected: +// true -> behave like a normal binary; read --video / --audio / --lyrics +// from the command line (all three are required). +// false -> use the hardcoded default paths below (the uninstall-hook path, +// invoked by dpkg with no arguments). +bool debugMode = true; + +const char* kDefaultVideoPath = "res/video.mp4"; +const char* kDefaultAudioPath = "res/music.flac"; +const char* kDefaultLyricPath = "res/lyric.lrc"; + +// Shared playback clock. Every module receives a pointer to this so the video, +// audio and lyrics stay in sync with each other. +Timeline g_timeline; + +// ---- helpers --------------------------------------------------------------- + +static void printUsage(const char* argv0) { + std::printf( + "Usage: %s --video --audio --lyrics \n" + "Play the show in the terminal: ASCII video at 60%% brightness,\n" + "music, and lyrics shown above the video on a shared timeline.\n" + "(debugMode = true; with debugMode = false the hardcoded defaults\n" + "in uninstall.cpp are used and no arguments are needed.)\n", + argv0); +} + +static int runShow(const std::string& videoPath, const std::string& audioPath, + const std::string& lyricPath) { + std::atomic stop{false}; + g_timeline.start(); + + bd::GridSize videoGrid; + int videoOffsetX = 0; + std::atomic gridReady{false}; + + bd::PlayVideoOptions vo; + vo.loop = true; + vo.intensity = 0.6f; // 60% brightness + vo.topRows = 1; // one row above the video reserved for the lyrics + vo.outGrid = &videoGrid; + vo.outOffsetX = &videoOffsetX; + vo.gridReady = &gridReady; + vo.stop = &stop; + + int videoResult = -1; + int musicResult = -1; + int lyricResult = -1; + + std::thread videoThread([&] { + videoResult = bd::playVideo(videoPath, &g_timeline, vo); + }); + std::thread musicThread([&] { musicResult = mp::play(audioPath, &g_timeline, &stop); }); + + // Wait for the video to report its grid, so the lyric line can be placed + // above the video and kept within the video's width. + for (int i = 0; i < 100 && !gridReady.load(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + lr::LyricDisplayOptions lo; + lo.row = 1; // the row above the video + lo.col = (videoGrid.cols > 0) ? videoOffsetX + 1 : 0; // the video's left edge + lo.maxWidth = (videoGrid.cols > 0) ? videoGrid.cols : 0; // within the video width + std::thread lyricThread([&] { + lyricResult = lr::playLyricsFile(lyricPath, lo, &g_timeline, &stop); + }); + + // The show ends when both the music and the lyrics have finished. + musicThread.join(); + lyricThread.join(); + stop.store(true); // stop the looping video + videoThread.join(); + + // Restore a clean terminal. + std::printf("\x1b[0m\x1b[2J\x1b[H\x1b[?25h"); + + if (videoResult == 1) { + std::fprintf(stderr, "uninstall: could not open video '%s'\n", videoPath.c_str()); + } + if (musicResult == 2) { + std::fprintf(stderr, "uninstall: could not open audio '%s'\n", audioPath.c_str()); + } else if (musicResult == 3) { + std::fprintf(stderr, "uninstall: no audio device available (playing without sound)\n"); + } + if (lyricResult != 0) { + std::fprintf(stderr, "uninstall: could not load lyrics '%s'\n", lyricPath.c_str()); + } return 0; } + +int main(int argc, char** argv) { + std::string videoPath, audioPath, lyricPath; + + if (debugMode) { + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if ((a == "--video" || a == "--audio" || a == "--lyrics") && i + 1 < argc) { + const std::string value = argv[++i]; + if (a == "--video") videoPath = value; + else if (a == "--audio") audioPath = value; + else if (a == "--lyrics") lyricPath = value; + } else if (a == "-h" || a == "--help") { + printUsage(argv[0]); + return 0; + } else { + printUsage(argv[0]); + return 1; + } + } + if (videoPath.empty() || audioPath.empty() || lyricPath.empty()) { + std::fprintf(stderr, + "error: --video, --audio and --lyrics are all required " + "(or set debugMode = false to use the hardcoded defaults).\n"); + printUsage(argv[0]); + return 1; + } + } else { + videoPath = kDefaultVideoPath; + audioPath = kDefaultAudioPath; + lyricPath = kDefaultLyricPath; + } + + return runShow(videoPath, audioPath, lyricPath); +}