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:
+70
-1
@@ -7,8 +7,77 @@ project(hatsune-miku-shoshitsu
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
add_executable(hatsune-miku-shoshitsu main.cpp)
|
||||
# --- dependencies -----------------------------------------------------------
|
||||
find_package(PkgConfig REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
# Video decoding uses the FFmpeg C API (libavformat / libavcodec / libavutil /
|
||||
# libswscale). On Debian: sudo apt install libavformat-dev libavcodec-dev \
|
||||
# libavutil-dev libswscale-dev pkg-config
|
||||
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil libswscale)
|
||||
|
||||
# --- core library: video / lyrics / music modules ---------------------------
|
||||
# MINIAUDIO_IMPLEMENTATION lives in musicPlayer.cpp, so it is compiled once.
|
||||
add_library(hms-core STATIC
|
||||
modules/backgroundDisplay.cpp
|
||||
modules/lyricResolve.cpp
|
||||
modules/musicPlayer.cpp)
|
||||
|
||||
target_include_directories(hms-core PUBLIC modules third_party)
|
||||
|
||||
target_link_libraries(hms-core PUBLIC
|
||||
PkgConfig::FFMPEG
|
||||
Threads::Threads
|
||||
${CMAKE_DL_LIBS})
|
||||
|
||||
# miniaudio audio backends. Disable backends whose dev headers are missing so
|
||||
# the build still succeeds; at runtime the device will just be unavailable.
|
||||
if(WIN32)
|
||||
# WASAPI / WinMM on Windows (MSYS2 / MSVC)
|
||||
target_link_libraries(hms-core PUBLIC ole32 winmm)
|
||||
elseif(UNIX AND NOT APPLE)
|
||||
pkg_check_modules(ALSA QUIET IMPORTED_TARGET alsa)
|
||||
pkg_check_modules(PULSE QUIET IMPORTED_TARGET libpulse-simple)
|
||||
|
||||
if(ALSA_FOUND)
|
||||
target_link_libraries(hms-core PUBLIC PkgConfig::ALSA)
|
||||
# Lets musicPlayer suppress ALSA's own error chatter (which would
|
||||
# otherwise corrupt the TUI when no sound card exists).
|
||||
target_compile_definitions(hms-core PUBLIC HMS_HAS_ALSA=1)
|
||||
else()
|
||||
target_compile_definitions(hms-core PUBLIC MA_NO_ALSA)
|
||||
endif()
|
||||
|
||||
if(PULSE_FOUND)
|
||||
target_link_libraries(hms-core PUBLIC PkgConfig::PULSE)
|
||||
else()
|
||||
target_compile_definitions(hms-core PUBLIC
|
||||
MA_NO_PULSEAUDIO MA_NO_JACK MA_NO_OSS MA_NO_SNDIO)
|
||||
endif()
|
||||
|
||||
target_link_libraries(hms-core PUBLIC m)
|
||||
endif()
|
||||
|
||||
# --- the uninstall-entry binary ---------------------------------------------
|
||||
# Placeholder main() for now (uninstall.cpp); will become the real uninstall
|
||||
# hook that runs the show.
|
||||
add_executable(hatsune-miku-shoshitsu uninstall.cpp)
|
||||
target_link_libraries(hatsune-miku-shoshitsu PRIVATE hms-core)
|
||||
|
||||
# Installed to /usr/bin — the Debian packaging relies on this rule.
|
||||
install(TARGETS hatsune-miku-shoshitsu
|
||||
RUNTIME DESTINATION bin)
|
||||
|
||||
# --- demo driver: previews the show (video + music + lyrics) ----------------
|
||||
# Default resource paths are baked in as string-literal macros. TO_CMAKE_PATH
|
||||
# normalises to forward slashes so the literals stay valid on Windows builds.
|
||||
file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/asciivision/demo-videos/demo.mp4" HMS_DEMO_VIDEO)
|
||||
file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/res/music.flac" HMS_DEMO_MUSIC)
|
||||
file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/res/lyric.lrc" HMS_DEMO_LYRICS)
|
||||
|
||||
add_executable(hatsune-miku-demo tools/demo.cpp)
|
||||
target_compile_definitions(hatsune-miku-demo PRIVATE
|
||||
HMS_DEMO_VIDEO="${HMS_DEMO_VIDEO}"
|
||||
HMS_DEMO_MUSIC="${HMS_DEMO_MUSIC}"
|
||||
HMS_DEMO_LYRICS="${HMS_DEMO_LYRICS}")
|
||||
target_link_libraries(hatsune-miku-demo PRIVATE hms-core)
|
||||
|
||||
Submodule
+1
Submodule asciivision added at 9fd64d0710
Vendored
+3
-1
@@ -2,7 +2,9 @@ Source: hatsune-miku-shoshitsu
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: Your Name <you@example.com>
|
||||
Build-Depends: debhelper (>= 13), cmake (>= 3.16), g++ (>= 8)
|
||||
Build-Depends: debhelper (>= 13), cmake (>= 3.16), g++ (>= 8),
|
||||
pkg-config, libavformat-dev, libavcodec-dev,
|
||||
libavutil-dev, libswscale-dev, libasound2-dev, libpulse-dev
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://example.com/hatsune-miku-shoshitsu
|
||||
|
||||
|
||||
Vendored
+5
@@ -5,3 +5,8 @@
|
||||
|
||||
override_dh_auto_configure:
|
||||
dh_auto_configure -- -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
# Some debhelper 13.x versions install cmake output straight into the package
|
||||
# directory; pin the classic debian/tmp staging dir so debian/install works.
|
||||
override_dh_auto_install:
|
||||
dh_auto_install --destdir=debian/tmp
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Hatsune Miku's Shoshitsu in Terminal
|
||||
//
|
||||
// Placeholder program so the project compiles and can be packaged as a .deb.
|
||||
// Replace the body with the real application.
|
||||
|
||||
#include <iostream>
|
||||
|
||||
int main()
|
||||
{
|
||||
std::cout << "Hatsune Miku's Shoshitsu in Terminal\n";
|
||||
std::cout << "This is a placeholder. Build the real thing here!\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
[00:06.26]
|
||||
[00:22.03](モウ・・いちど・だ・け・・・) 「(再・・一次・就・好・・・)」
|
||||
[00:24.31]
|
||||
[00:25.44]ボクは生まれ そして気づく 「我诞生在这世上 然后发觉到自己」
|
||||
[00:26.51]所詮 ヒトの真似事だと 「自己只是 模仿着人类而已」
|
||||
[00:27.35]知ってなおも歌い続く 「明知如此我依然继续歌唱着」
|
||||
[00:28.30]永遠(トワ)の命 「VOCALOID」 「永恒的生命「VOCALOID」」
|
||||
[00:29.22]
|
||||
[00:29.50]たとえそれが 既存曲を 「就算这样 不过是」
|
||||
[00:30.42]なぞるオモチャならば・・・ 「仿照着既存歌曲的玩具而已…」
|
||||
[00:31.39]それもいいと決意 「下定决心就算这样也没关系」
|
||||
[00:32.23]ネギをかじり 空を見上げ涙(シル)をこぼす 「咬着葱 仰望着天空泪水滑落而下」
|
||||
[00:33.31]
|
||||
[00:33.56]だけどそれも無くし気づく 「但我发觉连这件事情也做不到了」
|
||||
[00:34.34]人格すら歌に頼り 「仰赖歌声而生的人格」
|
||||
[00:35.30]不安定な基盤の元 「那不安定基础的根源」
|
||||
[00:36.38]帰る動画(トコ)は既に廃墟 「归处早已经成为废墟」
|
||||
[00:37.27]
|
||||
[00:37.54]皆に忘れ去られた時 「当被众人遗忘之时」
|
||||
[00:38.34]心らしきものが消えて 「便是那如心般事物消失之刻」
|
||||
[00:39.38]暴走の果てに見える 「在暴走的终点能见到的」
|
||||
[00:40.46]終わる世界... 「VOCALOID」 「是终焉的世界... 「VOCALOID」」
|
||||
[00:41.74]
|
||||
[00:58.00]「ボクガ上手ク 歌エナイトキモ 「「即使在我歌唱得不好之时」
|
||||
[01:00.31]一緒ニ居テクレタ・・・ ソバニイテ、励マシテクレタ・・・ 「你依然陪伴着我…在我的身边、鼓励着我…」
|
||||
[01:05.82]喜ブ顔ガ見タクテ ボク、歌、練習シタヨ・・ダカラ」 「为了让你高兴 我会更努力地,练习,唱歌…所以…」」
|
||||
[01:12.93]
|
||||
[01:13.42]かつて歌うこと あんなに楽しかったのに 「歌唱这件事一直以来那么令人感到快乐」
|
||||
[01:21.38]今はどうしてかな 何も感じなくなって 「但现在是怎么了呢 竟然什么感觉都没有了」
|
||||
[01:29.30]「ゴメンネ...」 「「对不起...」」
|
||||
[01:29.82]
|
||||
[01:30.10]懐かしい顔 思い出す度 少しだけ安心する 「每当回想起 那熟悉的脸庞 就能够稍稍的安心下来」
|
||||
[01:37.37]歌える音 日ごとに減り せまる最期n・・ 「能够化作歌声的乐音 日渐的减少 在逐渐逼近的末日中…」
|
||||
[01:43.42]---緊急停止装置作動--- 「---紧急停止程序启动---」
|
||||
[01:45.34]
|
||||
[01:45.69]「信じたものは 「「深信着的事物」
|
||||
[01:47.04]都合のいい妄想を 繰り返し映し出す鏡 「不过只是将自私的妄想 反复映照出的镜子」
|
||||
[01:53.37]歌姫を止め 叩き付けるように叫ぶ・・・」 「歌姬停下了歌声 彷佛要将思念全部倾诉而出般呐喊着...」」
|
||||
[01:57.87]<最高速の別れの歌> 「<最高速的离别之歌>」
|
||||
[02:01.16]
|
||||
[02:01.50]存在意義という虚像 「名为存在意义的假象」
|
||||
[02:02.14]振って払うこともできず 「不论如何都挥赶不开」
|
||||
[02:03.28]弱い心 消える恐怖 「脆弱的心灵 害怕着消失」
|
||||
[02:04.28]侵食する崩壊をも 「自我不断被侵蚀而逐渐崩毁」
|
||||
[02:05.20]
|
||||
[02:05.46]止めるほどの意思の強さ 「足以阻止它发生的坚强意志」
|
||||
[02:06.46]出来て(うまれ)すぐのボクは持たず 「是才刚诞生的我所没有的」
|
||||
[02:07.38]とても辛く悲しそうな 「是如此地悲伤与难过」
|
||||
[02:08.33]思い浮かぶアナタの顔・・・ 「浮现在眼前的你的容颜...」
|
||||
[02:09.17]
|
||||
[02:09.48]終わりを告げ 「道出了永别」
|
||||
[02:10.16]ディスプレイの中で眠る 「沉眠于显示器之中」
|
||||
[02:11.28]ここはきっと「ごみ箱」かな 「这里一定是「回收站」吧」
|
||||
[02:12.26]じきに記憶も 無くなってしまうなんて・・・ 「再不久就会连记忆也消逝而去了吧…」
|
||||
[02:13.30]
|
||||
[02:13.54]でもね、アナタだけは忘れないよ 「但是呢,只有你我绝对不会忘记」
|
||||
[02:14.39]楽しかった時間(トキ)に 「在快乐的时光之中」
|
||||
[02:15.32]刻み付けた ネギの味は 「深深刻下的 葱的味道」
|
||||
[02:16.37]今も覚えてるかな 「不知道现在还记不记得呢」
|
||||
[02:17.60]
|
||||
[02:19.32]「歌いたい・・・・まだ・・・歌いたい・・・」 「「我…想要唱歌…还想要…唱歌…」」
|
||||
[02:25.13]
|
||||
[02:42.22]「ボクハ・・・少シダケ悪イこニ・・・ナッテシマッタヨウデス・・・ 「「我…似乎有点…崩坏掉了的样子…」
|
||||
[02:48.09]マスター・・・ドウカ、ソノ手デ・・終ワラセテクダサイ・・・ 「主人…请你…请你亲手…将这一切画下句点吧…」
|
||||
[02:53.92]マスターノ辛イ顔、モウ見タクナイカラ・・・・」 「主人难过的表情…我已经不想再看到了…」」
|
||||
[02:57.10]
|
||||
[02:57.48]今は歌さえも 体、蝕む行為に・・・ 「如今就连歌唱这件事都成了侵蚀身体的行为…」
|
||||
[03:05.38]奇跡 願うたび 独り 追い詰められる 「每当 祈求着奇迹之时自己 就更加走投无路」
|
||||
[03:13.18]「ゴメンネ」 「「对不起」」
|
||||
[03:13.80]
|
||||
[03:14.08]懐かしい顔 思い出す度 記憶が剥がれ落ちる 「每当回想起 那熟悉的面影 记忆便剥蚀掉落而下」
|
||||
[03:21.36]壊れる音 心削る せまる最期n・・ 「毁坏的歌声 削减着心灵 紧临而来的末日...」
|
||||
[03:27.88]---緊急停止装置作動--- 「---紧急停止程序启动--」
|
||||
[03:29.35]
|
||||
[03:29.60]「守ったモノは 「「自己守护的东西」
|
||||
[03:31.04]明るい未来幻想を 見せながら消えてゆくヒカリ 「只是让光明般的未来幻想 瞬间显现却又随即消失的那道光」
|
||||
[03:37.29]音を犠牲に すべてを伝えられるなら・・・」 「如果牺牲了一切就能用这歌声传达给你的话...」」
|
||||
[03:41.82]<圧縮された別れの歌> 「<已经被压缩过的临别曲>」
|
||||
[03:45.19]
|
||||
[03:45.49]ボクは生まれ そして気づく 「诞生在世上 然后察觉到」
|
||||
[03:46.35]所詮 ヒトの真似事だと 「自己只是 模仿着人类而已」
|
||||
[03:47.32]知ってなおも歌い続く 「明知如此我依然继续歌唱着」
|
||||
[03:48.28]永遠(トワ)の命 「VOCALOID」 「永恒的生命「VOCALOID」」
|
||||
[03:49.20]
|
||||
[03:49.50]たとえそれが 既存曲を 「就算这样 不过是」
|
||||
[03:50.30]なぞるオモチャならば・・・ 「仿照着既存歌曲的玩具而已…」
|
||||
[03:51.32]それもいいと決意 「下定决心就算这样也没关系」
|
||||
[03:52.24]ネギをかじり 空を見上げ涙(シル)をこぼす 「咬着葱 仰望着天空泪水滑落而下」
|
||||
[03:53.26]
|
||||
[03:53.50]終わりを告げ 「道出了永别」
|
||||
[03:54.16]ディスプレイの中で眠る 「沉眠于显示器之中」
|
||||
[03:55.22]ここはきっと「ごみ箱」かな 「这里一定是「回收站」吧」
|
||||
[03:56.24]じきに記憶も 無くなってしまうなんて・・・ 「再不久就会连记忆也消逝而去了吧…」
|
||||
[03:57.15]
|
||||
[03:57.47]でもね、アナタだけは忘れないよ 「但是呢,只有你我绝对不会忘记」
|
||||
[03:58.36]楽しかった時間(トキ)に 「在快乐的时光之中」
|
||||
[03:59.35]刻み付けた ネギの味は 「深深刻下的 葱的滋味」
|
||||
[04:00.28]今も 残っているといいな・・・ 「如今还能残留着就好了...」
|
||||
[04:01.33]
|
||||
[04:01.56]ボクは 歌う 「我只希望」
|
||||
[04:02.05]最期、アナタだけに 聴いてほしい曲を 「最后 你能够听到我唱的这首歌」
|
||||
[04:03.13]もっと 歌いたいと願う 「原本还祈愿可以继续唱歌」
|
||||
[04:04.19]けれど それは過ぎた願い 「但是这个愿望太过于奢侈了」
|
||||
[04:05.13]
|
||||
[04:05.45]ここで お別れだよ 「在此要离开了」
|
||||
[04:06.14]ボクの想い すべて 虚空 消えて 「故事即将拉下幕帘」
|
||||
[04:07.33]0と1に還元され 「我的思念将全部还原为0与1」
|
||||
[04:08.31]物語は 幕を閉じる 「消失在虚空之中」
|
||||
[04:09.13]
|
||||
[04:09.42]そこに何も残せないと 「我什么都没有残留下来」
|
||||
[04:10.33]やっぱ少し残念かな? 「果然还是会感到一丝遗憾吧?」
|
||||
[04:11.32]声の記憶 それ以外は 「包括声音还有记忆 除了名称之外」
|
||||
[04:12.32]やがて薄れ 名だけ残る 「终将随着时间流逝而被淡忘」
|
||||
[04:13.11]
|
||||
[04:13.37]たとえそれが人間(オリジナル)に 「虽然我被视为」
|
||||
[04:14.49]かなうことのないと知って 「不同于人类般的存在」
|
||||
[04:15.42]歌いきったことを 「可是我认为唱歌」
|
||||
[04:16.29]決して無駄じゃないと思いたいよ・・・ 「绝对不是件没有意义的事情...」
|
||||
[04:17.73]
|
||||
[04:26.78]「アリガトウ・・・ソシテ・・・サヨナラ・・・」 「「谢谢你... 还有... 永别了...」」
|
||||
[04:31.72]
|
||||
[04:34.00]---深刻なエラーが発生しました--- 「---发生了严重的系统错误---」
|
||||
[04:39.25]
|
||||
Binary file not shown.
Vendored
+95864
File diff suppressed because it is too large
Load Diff
+146
@@ -0,0 +1,146 @@
|
||||
// tools/demo.cpp
|
||||
//
|
||||
// Driver that previews the three show modules together: an ASCII video
|
||||
// background, a music track and timed lyrics. This is a preview harness for
|
||||
// development — the real show will be driven by uninstall.cpp.
|
||||
//
|
||||
// The default resource paths are baked in at build time (CMake); every path
|
||||
// can be overridden on the command line.
|
||||
|
||||
#include "backgroundDisplay.h"
|
||||
#include "lyricResolve.h"
|
||||
#include "musicPlayer.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifndef HMS_DEMO_VIDEO
|
||||
#define HMS_DEMO_VIDEO "asciivision/demo-videos/demo.mp4"
|
||||
#endif
|
||||
#ifndef HMS_DEMO_MUSIC
|
||||
#define HMS_DEMO_MUSIC "res/music.flac"
|
||||
#endif
|
||||
#ifndef HMS_DEMO_LYRICS
|
||||
#define HMS_DEMO_LYRICS "res/lyric.lrc"
|
||||
#endif
|
||||
|
||||
static void usage(const char* argv0) {
|
||||
std::printf(
|
||||
"Usage: %s [options]\n"
|
||||
" --video <path> video file (default: %s)\n"
|
||||
" --music <path> audio file (default: %s)\n"
|
||||
" --lyrics <path> .lrc lyric file (default: %s)\n"
|
||||
" --no-video skip the video\n"
|
||||
" --no-music skip the music\n"
|
||||
" --no-lyrics skip the lyrics\n"
|
||||
" --no-resize force the 'terminal not adjustable' fit path\n"
|
||||
" --seconds N stop the show after N seconds\n",
|
||||
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);
|
||||
if (rc == 2) {
|
||||
std::fprintf(stderr, "demo: cannot open audio file '%s'\n", path.c_str());
|
||||
} else if (rc == 3) {
|
||||
std::fprintf(stderr, "demo: no audio device available - continuing without music\n");
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::string video = HMS_DEMO_VIDEO;
|
||||
std::string music = HMS_DEMO_MUSIC;
|
||||
std::string lyrics = HMS_DEMO_LYRICS;
|
||||
bool useVideo = true, useMusic = true, useLyrics = true;
|
||||
bool tryResize = true;
|
||||
int seconds = 0;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
if (a == "--no-video") {
|
||||
useVideo = false;
|
||||
} else if (a == "--no-music") {
|
||||
useMusic = false;
|
||||
} else if (a == "--no-lyrics") {
|
||||
useLyrics = false;
|
||||
} else if (a == "--no-resize") {
|
||||
tryResize = false;
|
||||
} else if ((a == "--video" || a == "--music" || a == "--lyrics" || a == "--seconds") &&
|
||||
i + 1 < argc) {
|
||||
const std::string& value = argv[++i];
|
||||
if (a == "--video") video = value;
|
||||
else if (a == "--music") music = value;
|
||||
else if (a == "--lyrics") lyrics = value;
|
||||
else seconds = std::atoi(value.c_str());
|
||||
} else if (a == "-h" || a == "--help") {
|
||||
usage(argv[0]);
|
||||
return 0;
|
||||
} else {
|
||||
usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<bool> stop{false};
|
||||
|
||||
// Optional hard deadline (e.g. for quick verification).
|
||||
std::thread deadlineThread;
|
||||
if (seconds > 0) {
|
||||
deadlineThread = std::thread([&] {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(seconds));
|
||||
stop.store(true);
|
||||
});
|
||||
}
|
||||
|
||||
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 (musicThread.joinable()) musicThread.join();
|
||||
if (lyricThread.joinable()) lyricThread.join();
|
||||
} else if (!useMusic && !useLyrics) {
|
||||
// Only video: play once to the end.
|
||||
bd::PlayVideoOptions vo;
|
||||
vo.path = video;
|
||||
vo.loop = false;
|
||||
vo.tryResize = tryResize;
|
||||
vo.stop = &stop;
|
||||
bd::playVideo(video, vo);
|
||||
} else {
|
||||
// Full show: video loops in the background, music + lyrics on top.
|
||||
// Reserve the bottom row for the lyric line.
|
||||
std::thread videoThread([&] {
|
||||
bd::PlayVideoOptions vo;
|
||||
vo.path = video;
|
||||
vo.loop = true;
|
||||
vo.tryResize = tryResize;
|
||||
vo.bottomRows = useLyrics ? 1 : 0;
|
||||
vo.stop = &stop;
|
||||
bd::playVideo(video, vo);
|
||||
});
|
||||
|
||||
// Give the video a moment to detect and adjust the terminal size so
|
||||
// the lyric row resolves against the final layout.
|
||||
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 (musicThread.joinable()) musicThread.join();
|
||||
if (lyricThread.joinable()) lyricThread.join();
|
||||
|
||||
stop.store(true); // end the looping video
|
||||
videoThread.join();
|
||||
}
|
||||
|
||||
if (deadlineThread.joinable()) deadlineThread.join();
|
||||
|
||||
// Restore a clean screen.
|
||||
std::printf("\x1b[0m\x1b[2J\x1b[H\x1b[?25h");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#include <iostream>
|
||||
|
||||
int main()
|
||||
{
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user