feat: make everything threads-safe.

feat: complete `uninstall.sh` logic to make everything synced.
	feat: complete basic play logic to handle background, music,
	lyric play.
This commit is contained in:
2026-08-30 15:13:28 +08:00
parent 00b7c0b324
commit 7edba3fbc0
10 changed files with 355 additions and 65 deletions
+48 -18
View File
@@ -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<int>(std::lround(
ts.cols * static_cast<double>(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<int64_t>(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<size_t>(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::steady_clock::duration>(
std::chrono::duration<double>(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();
+11 -3
View File
@@ -22,6 +22,8 @@
#include <string>
#include <atomic>
#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<bool>* gridReady = nullptr; // set true once the layout is reported
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).
// 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
+68 -26
View File
@@ -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<size_t>(len);
i += static_cast<size_t>(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<bool>* stop) {
Timeline* timeline, std::atomic<bool>* 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<int>(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<bool>* stop) {
Timeline* timeline, std::atomic<bool>* stop) {
const LyricInfo info = parseLrcFile(path);
if (!info.valid) return 1;
return playLyrics(info, opts, stop);
return playLyrics(info, opts, timeline, stop);
}
} // namespace lr
+10 -2
View File
@@ -14,6 +14,8 @@
#include <string>
#include <vector>
#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<bool>* stop = nullptr);
int playLyricsFile(const std::string& path,
const LyricDisplayOptions& opts = {},
Timeline* timeline = nullptr,
std::atomic<bool>* stop = nullptr);
} // namespace lr
+5 -1
View File
@@ -73,9 +73,13 @@ void dataCallback(ma_device* device, void* out, const void* /*in*/,
} // namespace
int play(const std::string& path, std::atomic<bool>* stop) {
int play(const std::string& path, Timeline* timeline, std::atomic<bool>* 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) {
+8 -3
View File
@@ -10,13 +10,18 @@
#include <atomic>
#include <string>
#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<bool>* stop = nullptr);
int play(const std::string& path, Timeline* timeline = nullptr,
std::atomic<bool>* stop = nullptr);
} // namespace mp
+50
View File
@@ -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 <chrono>
#include <mutex>
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<std::mutex> 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<std::mutex> lock(mutex_);
return started_;
}
// Seconds elapsed since the anchor (0 until start()).
double seconds() const {
std::lock_guard<std::mutex> lock(mutex_);
if (!started_) return 0.0;
return std::chrono::duration<double>(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<std::mutex> lock(mutex_);
return start_;
}
private:
mutable std::mutex mutex_;
std::chrono::steady_clock::time_point start_{};
bool started_ = false;
};
BIN
View File
Binary file not shown.
+12 -8
View File
@@ -10,6 +10,7 @@
#include "backgroundDisplay.h"
#include "lyricResolve.h"
#include "musicPlayer.h"
#include "timeline.h"
#include <atomic>
#include <chrono>
@@ -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<bool>* stop) {
const int rc = mp::play(path, stop);
static void playMusicQuietly(const std::string& path, Timeline* timeline,
std::atomic<bool>* 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<bool> 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();
+143 -4
View File
@@ -1,7 +1,146 @@
#include <iostream>
// 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 <atomic>
#include <chrono>
#include <cstdio>
#include <string>
#include <thread>
// ---- 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 <file> --audio <file> --lyrics <file>\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<bool> stop{false};
g_timeline.start();
bd::GridSize videoGrid;
int videoOffsetX = 0;
std::atomic<bool> 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);
}