From 4ed4e232823cad98937c0677a8fb6a93a80f7b0b Mon Sep 17 00:00:00 2001 From: Wang Zichong Date: Mon, 12 Oct 2026 13:32:16 +0800 Subject: [PATCH] feat(ui): add floating caption window with live transcription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a frameless always-on-top caption window wired to the live capture pipeline. Silence-based segmentation gates the audio so the model never sees silence, and the panel background is painted in paintEvent with a configurable opacity instead of a style sheet. 新增悬浮字幕窗(无边框、置顶、可折叠/展开),接入实时采集与流式转写。 分段由静音检测驱动,静音期间不送音频,避免模型对静音产生幻觉。底板 改为在 paintEvent 中绘制,不透明度可调。 --- CMakeLists.txt | 7 +- README.md | 2 +- docs/images/phase3-en-collapsed.png | Bin 0 -> 18169 bytes docs/images/phase3-en-expanded.png | Bin 0 -> 19576 bytes docs/images/phase3-opacity50.png | Bin 0 -> 17970 bytes docs/images/phase3-zh-collapsed.png | Bin 0 -> 17522 bytes docs/phase3-caption-window.md | 77 ++++++ src/captionwindow.cpp | 238 +++++++++++++++++ src/captionwindow.h | 63 +++++ src/livetranscriber.cpp | 178 +++++++++++++ src/livetranscriber.h | 52 ++++ src/main.cpp | 400 +++++++++++----------------- src/streamingasr.cpp | 41 ++- src/streamingasr.h | 4 +- 14 files changed, 803 insertions(+), 259 deletions(-) create mode 100644 docs/images/phase3-en-collapsed.png create mode 100644 docs/images/phase3-en-expanded.png create mode 100644 docs/images/phase3-opacity50.png create mode 100644 docs/images/phase3-zh-collapsed.png create mode 100644 docs/phase3-caption-window.md create mode 100644 src/captionwindow.cpp create mode 100644 src/captionwindow.h create mode 100644 src/livetranscriber.cpp create mode 100644 src/livetranscriber.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f6a3561..1290f3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,10 +25,12 @@ foreach(_p endif() endforeach() -find_package(Qt6 REQUIRED COMPONENTS Core) +find_package(Qt6 REQUIRED COMPONENTS Core Widgets) find_package(PkgConfig REQUIRED) find_package(Threads REQUIRED) +set(CMAKE_AUTOMOC ON) + pkg_check_modules(SAMPLERATE REQUIRED IMPORTED_TARGET samplerate) pkg_check_modules(PULSE REQUIRED IMPORTED_TARGET libpulse) @@ -67,12 +69,15 @@ add_executable(live-caption-cli src/qwen3asr.cpp src/streamingasr.cpp src/audio.cpp + src/livetranscriber.cpp + src/captionwindow.cpp ) target_include_directories(live-caption-cli PRIVATE src) target_link_libraries(live-caption-cli PRIVATE Qt6::Core + Qt6::Widgets Threads::Threads PkgConfig::SAMPLERATE PkgConfig::PULSE diff --git a/README.md b/README.md index 5d992bc..d8ac70d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Linux 桌面实时字幕:采集系统正在播放的声音,实时转写成 | Phase 0 | 可行性验证(采集 / 模型 / CPU 实时性) | ✅ 见 [docs/phase0-verification.md](docs/phase0-verification.md) | | Phase 1 | 命令行原型(进程内推理 + monitor 采集 + 滑窗 + 稳定前缀) | ✅ 见 [experiments/phase1/README.md](experiments/phase1/README.md) | | Phase 2 | 原生流式解码(8s 切片 + 增量 KV 复用 + 前缀回滚) | ✅ 见 [docs/phase2-streaming.md](docs/phase2-streaming.md) | -| Phase 3 | Qt 悬浮字幕窗(折叠/展开、语言标签、置顶) | 待做 | +| Phase 3 | Qt 悬浮字幕窗(折叠/展开、语言标签、置顶、实时链路) | ✅ 见 [docs/phase3-caption-window.md](docs/phase3-caption-window.md) | | Phase 4 | 设置界面(模型切换、语言、字体、位置) | 待做 | | Phase 5 | Wayland 支持、打包、模型分发 | 待做 | diff --git a/docs/images/phase3-en-collapsed.png b/docs/images/phase3-en-collapsed.png new file mode 100644 index 0000000000000000000000000000000000000000..9fbabdbd2d259b1a99aa67b1b615335bb9cd3df3 GIT binary patch literal 18169 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 E0=c*1DF6Tf literal 0 HcmV?d00001 diff --git a/docs/images/phase3-en-expanded.png b/docs/images/phase3-en-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..bb532652b95fa95dfcbe1032763121f556aec1b9 GIT binary patch literal 19576 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 literal 0 HcmV?d00001 diff --git a/docs/images/phase3-opacity50.png b/docs/images/phase3-opacity50.png new file mode 100644 index 0000000000000000000000000000000000000000..5524a36d5576ccafc5ae95754a420709e955ca8b GIT binary patch literal 17970 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 G@&5u|;aOJz literal 0 HcmV?d00001 diff --git a/docs/phase3-caption-window.md b/docs/phase3-caption-window.md new file mode 100644 index 0000000..34afb47 --- /dev/null +++ b/docs/phase3-caption-window.md @@ -0,0 +1,77 @@ +# Phase 3:Qt 悬浮字幕窗 + +状态:**完成**。悬浮窗可用,已接入实时采集 + 流式转写。 + +![折叠态(英文)](images/phase3-en-collapsed.png) + +![展开态(英文)](images/phase3-en-expanded.png) + +![折叠态(中文)](images/phase3-zh-collapsed.png) + +![不透明度 50%](images/phase3-opacity50.png) + +## 已实现 + +- **悬浮窗**:无边框、半透明深色圆角、始终置顶、不接受焦点(`Qt::FramelessWindowHint | WindowStaysOnTopHint | Tool | WindowDoesNotAcceptFocus`),可拖动。 +- **折叠 / 展开**:折叠两行、展开八行,底部箭头切换(折叠态 `⌄`、展开态 `⌃`)。 +- **语言标签**:左上角圆角 pill,显示识别到的语言(英语 / 中文 …);点击会发 `languageClicked` 信号,设置界面留到 Phase 4。 +- **稳定/不稳定区分**:已稳定文本白色,不稳定尾部灰色 —— 与 CLI 版一致,也是 Chrome 那种"字长出来"的观感。 +- **背景**:**黑色 + 可调不透明度**(不是全透明;纯黑底才能保证任何桌面背景下文字都可读)。默认 84%,范围限制 20~100,`--opacity` 或 `setPanelOpacity()` 可调,Phase 4 接到设置里即可。 +- **关闭**:右上角 `✕` 退出。 +- **实时链路**:采集默认输出的 monitor → 流式转写 → 信号推给窗口,全部在主线程外的工作线程里跑。 + +## 实时分段 + +Phase 2 的结论是段不能无限长(解码器对 KV 的注意力随总长增长)。Phase 3 用**静音检测**决定段边界: + +- 能量 VAD(噪声底用"快降慢升"的最小值跟踪,见 Phase 1 的教训); +- 检测到语音才开始把音频送进引擎,并保留 250ms 前导避免吃掉词头; +- **静音期间完全不送音频**:早期版本会把静音也喂进模型,模型对静音产生了幻觉(英文语音前冒出一个 `嗯。`); +- 静音超过 0.8s 就收束这一段(提交文本、清空 KV),下一段重新开始。 + +`--stream-segment`(默认 30s)仍作为兜底上界。 + +## 代码结构 + +``` +src/ +├─ captionwindow.{h,cpp} # 悬浮窗 +├─ livetranscriber.{h,cpp} # 采集 + VAD + 分段 + 流式引擎(QObject) +├─ streamingasr.{h,cpp} # 流式策略(前缀回滚、8s 切片、分段) +├─ qwen3asr.{h,cpp} # 进程内推理(libllama + libmtmd) +├─ audio.{h,cpp} # WAV 读取 + monitor 采集 + 重采样 +└─ transcript.{h,cpp} # 输出解析与语言名映射 +``` + +## 过程中踩的坑 + +1. **静音幻觉**:第一版把采集到的静音也喂给了模型,模型对静音产生幻觉,英文语音前面冒出一个 `嗯。`。改为静音期间完全不送音频。 +2. **自定义 QWidget 子类的样式表背景不生效**:`CaptionWindow { background-color: rgba(0,0,0,215) }` 这条规则在真实渲染时**根本没画**,面板全透明,只有文字和按钮浮在桌面上。原因是 Qt 不会为自定义 `QWidget` 子类绘制样式表背景(需要 `WA_StyledBackground`,或自己画)。改成在 `paintEvent()` 里用 `QPainter::drawRoundedRect` 画底板 —— 这也是 `WA_TranslucentBackground` 顶层窗口的推荐做法。 +3. **验证方法骗了自己**:`QWidget::grab()` 的产物当时是合成到**深灰底色**上看的,「黑色 + 84%」和「全透明」在深色背景上几乎看不出区别,于是把第 2 条盖住了。改成**直接检查像素 alpha**(面板中央应为 `(0,0,0,≈214)`、圆角外应为 `(0,0,0,0)`),并合成到亮色背景上复核。 +4. **DPI 警告不关本程序的事**:`setHighDpiScaleFactorRoundingPolicy must be called before creating the QGuiApplication instance` 连 5 行的空白 Qt 程序也会打印 —— 是 Deepin 会话环境设了 `QT_SCALE_FACTOR_ROUNDING_POLICY` 导致的。 + +## 用法 + +```bash +# 默认就是悬浮窗模式 +./build/live-caption-cli --model MODEL --mmproj MMPROJ + +# 调整背景不透明度(20-100) +./build/live-caption-cli --model MODEL --mmproj MMPROJ --opacity 60 + +# 无窗口、纯命令行实时(便于在无显示环境调试) +./build/live-caption-cli --model MODEL --mmproj MMPROJ --cli-live + +# 自截图(验证渲染,不依赖外部截图工具);会依次截折叠态与展开态 +./build/live-caption-cli --model MODEL --mmproj MMPROJ \ + --screenshot out.png --screenshot-delay 12 +``` + +> 自截图产物带 alpha 通道;要判断不透明度是否正确,**直接看像素 alpha**,不要靠肉眼看合成效果。 + +## 已知限制 / 后续 + +- X11 下置顶与定位都正常;Wayland 下需要 layer-shell,属于 Phase 5。 +- 语言标签点击暂无设置界面(Phase 4)。 +- 采集跟随的是**启动时**的默认输出设备;设备切换(例如切到 HDMI)暂未处理。 +- 界面尚未做 HiDPI 之外的适配(当前主屏宽度 72%、最大 900 逻辑像素)。 diff --git a/src/captionwindow.cpp b/src/captionwindow.cpp new file mode 100644 index 0000000..993cf8f --- /dev/null +++ b/src/captionwindow.cpp @@ -0,0 +1,238 @@ +#include "captionwindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lc { + +namespace { + +constexpr int kCollapsedLines = 2; +constexpr int kExpandedLines = 8; +constexpr int kMargin = 12; +constexpr int kRadius = 10; + +QString escapeHtml(const QString & s) +{ + return s.toHtmlEscaped().replace(QLatin1Char('\n'), QLatin1String("
")); +} + +} // namespace + +CaptionWindow::CaptionWindow(QWidget * parent) + : QWidget(parent) +{ + // 无边框 + 置顶 + 不抢焦点(X11 下都有对应实现) + setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool + | Qt::WindowDoesNotAcceptFocus); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_ShowWithoutActivating); + setWindowTitle(QStringLiteral("实时字幕")); + + auto * root = new QVBoxLayout(this); + root->setContentsMargins(kMargin, kMargin, kMargin, kMargin); + root->setSpacing(6); + + // ---- 顶部:语言标签 + 关闭 ---- + auto * top = new QHBoxLayout(); + top->setSpacing(6); + + langPill_ = new QPushButton(this); + langPill_->setCursor(Qt::PointingHandCursor); + langPill_->setFocusPolicy(Qt::NoFocus); + langPill_->setToolTip(tr("识别语言(设置界面将在后续阶段接入)")); + connect(langPill_, &QPushButton::clicked, this, &CaptionWindow::languageClicked); + + closeBtn_ = new QPushButton(QStringLiteral("\u2715"), this); // ✕ + closeBtn_->setCursor(Qt::PointingHandCursor); + closeBtn_->setFocusPolicy(Qt::NoFocus); + closeBtn_->setFixedSize(22, 22); + connect(closeBtn_, &QPushButton::clicked, this, &CaptionWindow::closed); + + top->addWidget(langPill_); + top->addStretch(1); + top->addWidget(closeBtn_); + root->addLayout(top); + + // ---- 中间:字幕文本 ---- + text_ = new QTextEdit(this); + text_->setReadOnly(true); + text_->setFrameShape(QFrame::NoFrame); + text_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + text_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + text_->setTextInteractionFlags(Qt::NoTextInteraction); // 让拖动可以穿透到窗口 + text_->setFocusPolicy(Qt::NoFocus); + text_->viewport()->setAutoFillBackground(false); + root->addWidget(text_, 1); + + // ---- 底部:展开/收起 ---- + toggleBtn_ = new QPushButton(this); + toggleBtn_->setCursor(Qt::PointingHandCursor); + toggleBtn_->setFocusPolicy(Qt::NoFocus); + connect(toggleBtn_, &QPushButton::clicked, this, [this] { setCollapsed(!collapsed_); }); + + auto * bottom = new QHBoxLayout(); + bottom->addStretch(1); + bottom->addWidget(toggleBtn_); + bottom->addStretch(1); + root->addLayout(bottom); + + setStyleSheet(QString()); // 实际样式由 applyStyle() 生成(透明度可调) + applyStyle(); + + langPill_->setObjectName(QStringLiteral("langPill")); + toggleBtn_->setObjectName(QStringLiteral("toggleBtn")); + + relayout(); + setLanguage(QString()); + setCaption(QString(), QString()); + + // 默认停在主屏下方居中 + if (QScreen * screen = QApplication::primaryScreen()) { + const QRect avail = screen->availableGeometry(); + const int w = qMin(900, (int) (avail.width() * 0.72)); + resize(w, height()); + move(avail.center().x() - w / 2, avail.bottom() - height() - 48); + } +} + +void CaptionWindow::applyStyle() +{ + // 注意:这里不设 CaptionWindow 自己的 background。 + // 自定义 QWidget 子类默认不绘制样式表背景,而且带 WA_TranslucentBackground 的 + // 顶层窗口本来就该自己画底板 —— 见 paintEvent()。 + setStyleSheet(QStringLiteral(R"( + QPushButton { + color: #f0f0f0; + background-color: transparent; + border: none; + padding: 2px 10px; + font-size: 12px; + } + QPushButton:hover { background-color: rgba(255,255,255,40); border-radius: 9px; } + #langPill { + border: 1px solid rgba(255,255,255,140); + border-radius: 10px; + padding: 1px 10px; + } + #toggleBtn { color: #bbbbbb; font-size: 15px; padding: 0px; } + QTextEdit { + color: #ffffff; + background: transparent; + font-size: 15px; + } + )")); +} + +void CaptionWindow::paintEvent(QPaintEvent *) +{ + // 背景是"黑色 + 一定不透明度",不是全透明:这样才能保证任何桌面背景下文字都可读。 + const int alpha = qBound(20, opacityPercent_, 100) * 255 / 100; + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 0, 0, alpha)); + p.drawRoundedRect(rect(), kRadius, kRadius); +} + +void CaptionWindow::setPanelOpacity(int percent) +{ + const int clamped = qBound(20, percent, 100); + if (clamped == opacityPercent_) return; + opacityPercent_ = clamped; + applyStyle(); +} + +int CaptionWindow::lineHeight() const +{ + return QFontMetrics(text_->font()).lineSpacing(); +} + +int CaptionWindow::textHeight() const +{ + return lineHeight() * (collapsed_ ? kCollapsedLines : kExpandedLines) + + (int) text_->document()->documentMargin() * 2; +} + +void CaptionWindow::relayout() +{ + text_->setFixedHeight(textHeight()); + // 折叠态显示向下的箭头(点击展开),展开态显示向上的箭头(点击收起) + toggleBtn_->setText(collapsed_ ? QStringLiteral("\u2304") // ⌄ + : QStringLiteral("\u2303")); // ⌃ + toggleBtn_->setToolTip(collapsed_ ? tr("展开") : tr("收起")); + const int h = kMargin * 2 + 22 + 6 + textHeight() + 6 + 24; + setFixedHeight(h); + setMinimumWidth(320); + scrollToEnd(); +} + +void CaptionWindow::scrollToEnd() +{ + QTextCursor c = text_->textCursor(); + c.movePosition(QTextCursor::End); + text_->setTextCursor(c); + text_->ensureCursorVisible(); +} + +void CaptionWindow::setCollapsed(bool collapsed) +{ + if (collapsed_ == collapsed) return; + collapsed_ = collapsed; + relayout(); +} + +void CaptionWindow::setLanguage(const QString & language) +{ + language_ = language; + langPill_->setText(language.isEmpty() ? tr("自动") : language); + langPill_->adjustSize(); +} + +void CaptionWindow::setCaption(const QString & committed, const QString & tail) +{ + if (committed == committed_ && tail == tail_) return; + committed_ = committed; + tail_ = tail; + + // 已稳定部分正常色,不稳定尾部暗色 —— 和 CLI 版渲染一致 + const QString html = + QStringLiteral("
%1%2
") + .arg(escapeHtml(committed_), escapeHtml(tail_)); + text_->setHtml(html); + scrollToEnd(); +} + +void CaptionWindow::mousePressEvent(QMouseEvent * e) +{ + if (e->button() == Qt::LeftButton) { + dragging_ = true; + dragOffset_ = e->globalPosition().toPoint() - frameGeometry().topLeft(); + e->accept(); + } +} + +void CaptionWindow::mouseMoveEvent(QMouseEvent * e) +{ + if (dragging_ && (e->buttons() & Qt::LeftButton)) { + move(e->globalPosition().toPoint() - dragOffset_); + e->accept(); + } +} + +void CaptionWindow::mouseReleaseEvent(QMouseEvent * e) +{ + dragging_ = false; + e->accept(); +} + +} // namespace lc diff --git a/src/captionwindow.h b/src/captionwindow.h new file mode 100644 index 0000000..1cfe616 --- /dev/null +++ b/src/captionwindow.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +class QLabel; +class QPushButton; +class QTextEdit; + +namespace lc { + +// 悬浮字幕窗(对应 Chrome 实时字幕那种样式): +// 无边框、置顶、不接受焦点;折叠时两行,展开后多行。 +class CaptionWindow : public QWidget { + Q_OBJECT + +public: + explicit CaptionWindow(QWidget * parent = nullptr); + + // committed 为已稳定文本,tail 为不稳定尾部(暗色显示) + void setCaption(const QString & committed, const QString & tail); + void setLanguage(const QString & language); + + bool isCollapsed() const { return collapsed_; } + void setCollapsed(bool collapsed); + + // 背景不透明度(百分比)。范围限制在 20~100: + // 太低会让字幕在任何桌面背景下都读不清,所以不给到全透明。 + void setPanelOpacity(int percent); + int panelOpacity() const { return opacityPercent_; } + +Q_SIGNALS: + void languageClicked(); + void closed(); + +protected: + void mousePressEvent(QMouseEvent * e) override; + void mouseMoveEvent(QMouseEvent * e) override; + void mouseReleaseEvent(QMouseEvent * e) override; + void paintEvent(QPaintEvent * e) override; + +private: + int lineHeight() const; + int textHeight() const; // 按折叠/展开计算文本区高度 + void relayout(); + void scrollToEnd(); + void applyStyle(); + + QPushButton * langPill_ = nullptr; + QPushButton * closeBtn_ = nullptr; + QPushButton * toggleBtn_ = nullptr; + QTextEdit * text_ = nullptr; + + bool collapsed_ = true; + int opacityPercent_ = 84; // 背景不透明度(百分比) + bool dragging_ = false; + QPoint dragOffset_; + QString language_; + QString committed_; + QString tail_; +}; + +} // namespace lc diff --git a/src/livetranscriber.cpp b/src/livetranscriber.cpp new file mode 100644 index 0000000..d168ea5 --- /dev/null +++ b/src/livetranscriber.cpp @@ -0,0 +1,178 @@ +#include "livetranscriber.h" + +#include +#include + +#include "qwen3asr.h" + +namespace lc { + +namespace { + +constexpr int kFrameSamples = 320; // 20ms @16k +constexpr float kSilenceTimeout = 0.8f; // 静音多久算一句结束 +constexpr float kPrerollSec = 0.25f; // 语音开始前保留的一段,避免吃掉词头 + +// 能量 VAD。噪声底用"快降慢升"的最小值跟踪(不能用均值,否则轻音频会被全判成静音)。 +class Vad { +public: + bool feed(const float * frame, size_t n) + { + double acc = 0.0; + for (size_t i = 0; i < n; ++i) acc += (double) frame[i] * frame[i]; + const float rms = std::sqrt(acc / (double) n); + + if (rms < floor_) floor_ = rms; + else floor_ = floor_ * 0.9999f + rms * 0.0001f; + + return rms > std::max(floor_ * 3.0f, 0.0015f); + } + +private: + float floor_ = 0.01f; +}; + +} // namespace + +LiveTranscriber::LiveTranscriber(Qwen3Asr & asr, QObject * parent) + : QObject(parent), asr_(asr) {} + +LiveTranscriber::~LiveTranscriber() +{ + stop(); +} + +QString LiveTranscriber::deviceName() const +{ + return QString::fromStdString(capture_.deviceName()); +} + +bool LiveTranscriber::start(const StreamOptions & opts, QString * error) +{ + opts_ = opts; + + std::string err; + const bool ok = capture_.start(asr_.sampleRate(), + [this](const float * s, size_t n) { + { + std::lock_guard lk(mutex_); + pending_.insert(pending_.end(), s, s + n); + } + cv_.notify_one(); + }, + &err); + if (!ok) { + if (error) *error = QString::fromStdString(err); + return false; + } + + worker_ = std::thread([this] { workerLoop(); }); + return true; +} + +void LiveTranscriber::stop() +{ + { + std::lock_guard lk(mutex_); + quit_ = true; + } + cv_.notify_all(); + if (worker_.joinable()) worker_.join(); + + capture_.stop(); +} + +void LiveTranscriber::workerLoop() +{ + Vad vad; + StreamingAsr stream(asr_, opts_); + + float silenceAcc = 0.0f; + bool inSegment = false; + bool flushNow = false; + + std::vector buffer; // 跨批次的残留样本 + std::vector preroll; // 语音开始前保留的一小段,避免吃掉词头 + std::vector segAudio; // 本段待送进引擎的音频 + + const size_t prerollSamples = (size_t) (kPrerollSec * asr_.sampleRate()); + + const auto emitStep = [this](const StreamingAsr::Step & s) { + if (!s.ok) return; + Q_EMIT caption(QString::fromStdString(s.committed), + QString::fromStdString(s.tail), + QString::fromStdString(s.language)); + }; + + const auto feedSegment = [&] { + if (!segAudio.empty()) stream.feed(segAudio.data(), segAudio.size(), emitStep); + segAudio.clear(); + }; + + for (;;) { + std::vector chunk; + { + std::unique_lock lk(mutex_); + cv_.wait_for(lk, std::chrono::milliseconds(50), + [this] { return quit_ || !pending_.empty(); }); + if (quit_ && pending_.empty()) break; + chunk.swap(pending_); + } + + // 不足一帧的残留要接上,否则会持续丢样本 + buffer.insert(buffer.end(), chunk.begin(), chunk.end()); + + size_t off = 0; + for (; off + kFrameSamples <= buffer.size(); off += kFrameSamples) { + const float * frame = buffer.data() + off; + const bool speech = vad.feed(frame, kFrameSamples); + + if (!inSegment) { + // 静音期间什么都不送给模型:否则模型会对静音产生幻觉 + if (speech) { + inSegment = true; + silenceAcc = 0.0f; + segAudio.insert(segAudio.end(), preroll.begin(), preroll.end()); + preroll.clear(); + segAudio.insert(segAudio.end(), frame, frame + kFrameSamples); + } else { + preroll.insert(preroll.end(), frame, frame + kFrameSamples); + if (preroll.size() > prerollSamples) { + preroll.erase(preroll.begin(), + preroll.begin() + (long) (preroll.size() - prerollSamples)); + } + } + } else { + segAudio.insert(segAudio.end(), frame, frame + kFrameSamples); + if (speech) silenceAcc = 0.0f; + else silenceAcc += (float) kFrameSamples / asr_.sampleRate(); + if (silenceAcc >= kSilenceTimeout) { + flushNow = true; + off += kFrameSamples; // 这一帧已经并入 segAudio + break; + } + } + } + + if (!segAudio.empty()) feedSegment(); + + // 静音够久 → 收束这一段(提交文本并重置上下文) + if (flushNow) { + stream.flush(emitStep); + stream.reset(); + inSegment = false; + silenceAcc = 0.0f; + flushNow = false; + preroll.clear(); + } + + if (off > 0) buffer.erase(buffer.begin(), buffer.begin() + (long) off); + } + + if (inSegment) { + feedSegment(); + stream.flush(emitStep); + } +} + +} // namespace lc diff --git a/src/livetranscriber.h b/src/livetranscriber.h new file mode 100644 index 0000000..40858a5 --- /dev/null +++ b/src/livetranscriber.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "audio.h" +#include "streamingasr.h" + +namespace lc { + +class Qwen3Asr; + +// 实时字幕:采集默认输出 → 流式转写 → 用信号把文本推给 UI。 +// 分段由静音检测驱动:句子之间的自然停顿就收束一段、清空上下文, +// 这样解码器的上下文不会无限增长(见 docs/phase2-streaming.md)。 +class LiveTranscriber : public QObject { + Q_OBJECT + +public: + explicit LiveTranscriber(Qwen3Asr & asr, QObject * parent = nullptr); + ~LiveTranscriber() override; + + bool start(const StreamOptions & opts, QString * error = nullptr); + void stop(); + + QString deviceName() const; + +Q_SIGNALS: + // committed = 已稳定文本;tail = 不稳定尾部(UI 用暗色显示) + void caption(const QString & committed, const QString & tail, const QString & language); + void failed(const QString & message); + +private: + void workerLoop(); + + Qwen3Asr & asr_; + MonitorCapture capture_; + StreamOptions opts_; + std::thread worker_; + std::mutex mutex_; + std::condition_variable cv_; + std::vector pending_; + bool quit_ = false; +}; + +} // namespace lc diff --git a/src/main.cpp b/src/main.cpp index 0f0b484..f61a106 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,214 +1,29 @@ -#include #include #include -#include -#include #include -#include +#include #include -#include #include +#include #include #include +#include +#include +#include #include "audio.h" +#include "captionwindow.h" +#include "livetranscriber.h" #include "qwen3asr.h" #include "streamingasr.h" #include "transcript.h" namespace { -constexpr int kFrameSamples = 320; // 20ms @ 16k -constexpr float kSilenceTimeout = 0.8f; // 静音多久算一句结束 -constexpr float kMinSpeech = 0.5f; // 一句话至少多长才值得转写 -constexpr float kMaxWindow = 20.0f; // 窗口上限,超过强制收尾 - -// 两个字符串的最长公共前缀,且保证切在 UTF-8 字符边界上 -size_t utf8Lcp(const std::string & a, const std::string & b) -{ - const size_t n = std::min(a.size(), b.size()); - size_t i = 0; - while (i < n && a[i] == b[i]) ++i; - // 回退到不是 UTF-8 续字节(10xxxxxx)的位置 - while (i > 0 && (static_cast(a[i]) & 0xC0) == 0x80) --i; - return i; -} - -// 简单的能量 VAD。 -// 噪声底必须用"最小值跟踪"(快降慢升),不能用均值: -// 否则遇到本来就很轻的音频(例如 AISHELL 峰值 RMS 只有 0.03), -// 噪声底会一路抬升、阈值超过语音电平,把话音全判成静音。 -class Vad { -public: - // 返回该帧是否为语音 - bool feed(const float * frame, size_t n) - { - double acc = 0.0; - for (size_t i = 0; i < n; ++i) acc += (double) frame[i] * frame[i]; - const float rms = std::sqrt(acc / (double) n); - - if (rms < floor_) { - floor_ = rms; // 快速下探 - } else { - floor_ = floor_ * 0.9999f + rms * 0.0001f; // 极慢回升 - } - - const float threshold = std::max(floor_ * 3.0f, 0.0015f); - speech_ = rms > threshold; - return speech_; - } - - bool speaking() const { return speech_; } - -private: - float floor_ = 0.01f; - bool speech_ = false; -}; - -// 实时转写:采集线程 -> 队列 -> 工作线程(VAD + 滑窗 + 转写) -class LiveTranscriber { -public: - LiveTranscriber(lc::Qwen3Asr & asr, int sampleRate) : asr_(asr), sampleRate_(sampleRate) {} - - void onAudio(const float * samples, size_t n) - { - std::lock_guard lk(mutex_); - pending_.insert(pending_.end(), samples, samples + n); - cv_.notify_one(); - } - - void start() { worker_ = std::thread([this] { run(); }); } - - void stop() - { - { - std::lock_guard lk(mutex_); - quit_ = true; - } - cv_.notify_all(); - if (worker_.joinable()) worker_.join(); - } - -private: - void emitPartial(const lc::Qwen3Asr::Result & r) - { - // 只把"跨多次转写都不变"的前缀当成已定文本,尾部标为不稳定预览。 - // 窗口在词中间被切断时,模型会给尾部编一个结尾,这样能避免它上屏。 - committed_ = r.transcript.text.substr(0, utf8Lcp(committed_, r.transcript.text)); - const std::string tail = r.transcript.text.substr(committed_.size()); - - std::lock_guard lk(printMutex_); - std::printf("\r\033[K~ [%s] %s\033[2m%s\033[0m", - lc::languageDisplayName(r.transcript.language).c_str(), - committed_.c_str(), tail.c_str()); - std::fflush(stdout); - } - - void emitFinal(const lc::Qwen3Asr::Result & r) - { - std::lock_guard lk(printMutex_); - std::printf("\r\033[K= [%s] %s\n", - lc::languageDisplayName(r.transcript.language).c_str(), - r.transcript.text.c_str()); - std::fflush(stdout); - committed_.clear(); - } - - lc::Qwen3Asr::Result transcribeWindow(const std::vector & window) - { - lc::Qwen3Asr::Result r = asr_.transcribe(window.data(), window.size()); - const double dur = (double) window.size() / sampleRate_; - std::lock_guard lk(printMutex_); - std::fprintf(stderr, " [infer] %.2fs 音频 / %.2fs 计算 (rtf %.3f)\n", - dur, r.inferSeconds, dur > 0 ? r.inferSeconds / dur : 0.0); - return r; - } - - void run() - { - Vad vad; - std::vector window; // 当前这句话累积的音频 - std::vector buffer; // 跨批次残留,保证不丢样本 - float silenceAcc = 0.0f; - float lastPartialAt = 0.0f; // 上次做 partial 时窗口长度(秒) - - for (;;) { - std::vector chunk; - { - std::unique_lock lk(mutex_); - cv_.wait_for(lk, std::chrono::milliseconds(50), [this] { return quit_ || !pending_.empty(); }); - if (quit_ && pending_.empty()) break; - chunk.swap(pending_); - } - - // 关键:把上一轮不足一帧的残留接上,否则会持续丢样本 - buffer.insert(buffer.end(), chunk.begin(), chunk.end()); - - size_t off = 0; - for (; off + kFrameSamples <= buffer.size(); off += kFrameSamples) { - const float * frame = buffer.data() + off; - const bool speech = vad.feed(frame, kFrameSamples); - - if (speech) { - if (window.empty()) lastPartialAt = 0.0f; - window.insert(window.end(), frame, frame + kFrameSamples); - silenceAcc = 0.0f; - } else if (!window.empty()) { - window.insert(window.end(), frame, frame + kFrameSamples); - silenceAcc += (float) kFrameSamples / sampleRate_; - } - - if (window.empty()) continue; - - const float windowSec = (float) window.size() / sampleRate_; - const float newAudioSincePartial = windowSec - lastPartialAt; - const float needed = std::max(0.8f, 0.35f * windowSec); - - // 静音够久 -> 收尾 - if (silenceAcc >= kSilenceTimeout && windowSec >= kMinSpeech) { - emitFinal(transcribeWindow(window)); - window.clear(); - silenceAcc = 0.0f; - lastPartialAt = 0.0f; - continue; - } - - // 窗口过长 -> 强制收尾,避免延迟无限增长 - if (windowSec >= kMaxWindow) { - emitFinal(transcribeWindow(window)); - window.clear(); - silenceAcc = 0.0f; - lastPartialAt = 0.0f; - continue; - } - - // 定期输出 partial(预览) - if (newAudioSincePartial >= needed) { - emitPartial(transcribeWindow(window)); - lastPartialAt = windowSec; - } - } - - if (off > 0) buffer.erase(buffer.begin(), buffer.begin() + (long) off); - } - - if (!window.empty() && (float) window.size() / sampleRate_ >= kMinSpeech) { - emitFinal(transcribeWindow(window)); - } - } - - lc::Qwen3Asr & asr_; - int sampleRate_; - std::thread worker_; - std::mutex mutex_; - std::condition_variable cv_; - std::vector pending_; - bool quit_ = false; - std::mutex printMutex_; - std::string committed_; // 当前这句已提交的稳定前缀 -}; - +// --------------------------------------------------------------------------- +// 文件模式 +// --------------------------------------------------------------------------- int runFile(lc::Qwen3Asr & asr, const QString & path) { std::vector pcm; @@ -238,6 +53,9 @@ int runFile(lc::Qwen3Asr & asr, const QString & path) return 0; } +// --------------------------------------------------------------------------- +// 流式文件模式(把文件当音频流按块喂进去) +// --------------------------------------------------------------------------- int runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOptions & sop) { std::vector pcm; @@ -248,10 +66,9 @@ int runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOpti } const double dur = (double) pcm.size() / asr.sampleRate(); - std::fprintf(stderr, "流式转写: %s (%.2fs) chunk=%.1fs window=%.1fs " - "unfixed_token=%d unfixed_chunk=%d\n", - qUtf8Printable(path), dur, sop.chunkSec, sop.windowSec, - sop.unfixedTokenNum, sop.unfixedChunkNum); + std::fprintf(stderr, "流式转写: %s (%.2fs) chunk=%.1fs segment=%.1fs mode=%s\n", + qUtf8Printable(path), dur, sop.chunkSec, sop.maxUtteranceSec, + sop.incremental ? "incremental" : "window"); lc::StreamingAsr stream(asr, sop); const size_t chunkSamples = (size_t) (sop.chunkSec * asr.sampleRate()); @@ -260,15 +77,12 @@ int runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOpti std::printf("\n[chunk %2d] 窗口 %5.1fs / 编码 %5.2fs / 本步 %5.2fs%s\n", s.chunkIndex, s.windowSeconds, s.encodeSeconds, s.inferSeconds, s.ok ? "" : " <失败>"); - if (!s.ok) { - std::printf(" error: %s\n", s.error.c_str()); - } + if (!s.ok) std::printf(" error: %s\n", s.error.c_str()); std::printf(" 已提交: %s\n", s.committed.c_str()); std::printf(" 含尾部: %s\n", s.full.c_str()); std::fflush(stdout); }; - // 模拟音频按 chunk 到达 for (size_t off = 0; off < pcm.size(); off += chunkSamples) { const size_t n = std::min(chunkSamples, pcm.size() - off); stream.feed(pcm.data() + off, n, onStep); @@ -281,74 +95,162 @@ int runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOpti return 0; } -int runLive(lc::Qwen3Asr & asr) +// --------------------------------------------------------------------------- +// 命令行实时模式(无窗口,便于在无显示环境下调试) +// --------------------------------------------------------------------------- +int runCliLive(lc::Qwen3Asr & asr, const lc::StreamOptions & sop) { - LiveTranscriber live(asr, asr.sampleRate()); - lc::MonitorCapture capture; + lc::LiveTranscriber live(asr); - std::string err; - if (!capture.start(asr.sampleRate(), [&live](const float * s, size_t n) { live.onAudio(s, n); }, &err)) { - std::fprintf(stderr, "启动采集失败: %s\n", err.c_str()); + QObject::connect(&live, &lc::LiveTranscriber::caption, + [](const QString & committed, const QString & tail, const QString & lang) { + // 用 \r 原地刷新:稳定部分正常显示,尾部加括号表示未定 + std::printf("\r\033[K[%s] %s\033[2m%s\033[0m", + lc::languageDisplayName(lang.toStdString()).c_str(), + qUtf8Printable(committed), qUtf8Printable(tail)); + std::fflush(stdout); + }); + QObject::connect(&live, &lc::LiveTranscriber::failed, [](const QString & m) { + std::fprintf(stderr, "\n采集失败: %s\n", qUtf8Printable(m)); + }); + + QString err; + if (!live.start(sop, &err)) { + std::fprintf(stderr, "启动采集失败: %s\n", qUtf8Printable(err)); return 1; } + std::fprintf(stderr, "正在采集: %s (Ctrl+C 退出)\n", qUtf8Printable(live.deviceName())); + return QCoreApplication::exec(); +} - std::fprintf(stderr, "正在采集: %s (%d Hz)\n按 Ctrl+C 退出\n\n", - capture.deviceName().c_str(), asr.sampleRate()); +// --------------------------------------------------------------------------- +// 图形界面模式:悬浮字幕窗 +// --------------------------------------------------------------------------- +int runGui(lc::Qwen3Asr & asr, const lc::StreamOptions & sop, + const QString & screenshotPath, int screenshotDelaySec, int opacityPercent) +{ + lc::CaptionWindow window; + window.setPanelOpacity(opacityPercent); + lc::LiveTranscriber live(asr); - live.start(); - // 简单起见:主线程等待,Ctrl+C 结束进程 - for (;;) std::this_thread::sleep_for(std::chrono::seconds(1)); - return 0; + QObject::connect(&live, &lc::LiveTranscriber::caption, &window, + [&window](const QString & committed, const QString & tail, + const QString & lang) { + window.setLanguage( + QString::fromStdString(lc::languageDisplayName(lang.toStdString()))); + window.setCaption(committed, tail); + }); + QObject::connect(&live, &lc::LiveTranscriber::failed, &window, [](const QString & m) { + qWarning("采集失败: %s", qUtf8Printable(m)); + }); + QObject::connect(&window, &lc::CaptionWindow::closed, qApp, &QCoreApplication::quit); + QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, [&window] { + qInfo() << "语言标签被点击(设置界面将在 Phase 4 接入):" << window.isCollapsed(); + }); + + QString err; + if (!live.start(sop, &err)) { + qCritical("启动采集失败: %s", qUtf8Printable(err)); + return 1; + } + qInfo("正在采集: %s", qUtf8Printable(live.deviceName())); + + window.show(); + + if (!screenshotPath.isEmpty()) { + // 自截图:用于验证渲染(不依赖外部截图工具) + QTimer::singleShot(screenshotDelaySec * 1000, &window, [&window, screenshotPath] { + const bool ok1 = window.grab().save(screenshotPath); + + // 再截一张展开态的 + window.setCollapsed(false); + const QString expanded = screenshotPath; + QString expandedPath = expanded; + expandedPath.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png")); + const bool ok2 = window.grab().save(expandedPath); + + std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n", + ok1 ? "成功" : "失败", ok2 ? "成功" : "失败"); + QCoreApplication::quit(); + }); + } + + return QCoreApplication::exec(); } } // namespace int main(int argc, char ** argv) { - QCoreApplication app(argc, argv); - QCoreApplication::setApplicationName("live-caption"); + // 文件类模式不需要图形环境;实时模式(默认)需要 QApplication + bool wantsWidgets = true; + for (int i = 1; i < argc; ++i) { + const QString a = QString::fromLocal8Bit(argv[i]); + if (a == QLatin1String("--file") || a == QLatin1String("--stream-file")) { + wantsWidgets = false; + break; + } + } + + std::unique_ptr app; + if (wantsWidgets) { + // 说明意图:DPI 取整策略必须在 QGuiApplication 之前定。 + // (Deepin 的会话环境已经设了 QT_SCALE_FACTOR_ROUNDING_POLICY, + // 所以本机仍会看到 Qt 的那条提醒,那不是本程序的问题。) + QGuiApplication::setHighDpiScaleFactorRoundingPolicy( + Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); + app.reset(new QApplication(argc, argv)); + } else { + app.reset(new QCoreApplication(argc, argv)); + } + QCoreApplication::setApplicationName(QStringLiteral("live-caption")); QCommandLineParser parser; - parser.setApplicationDescription("实时字幕原型(Qwen3-ASR + llama.cpp)"); + parser.setApplicationDescription(QStringLiteral("实时字幕(Qwen3-ASR + llama.cpp)")); parser.addHelpOption(); QCommandLineOption optModel("model", "主模型 GGUF 路径(架构须为 qwen3vl)", "path"); QCommandLineOption optMmproj("mmproj", "mmproj GGUF 路径(clip + qwen3a)", "path"); - QCommandLineOption optFile("file", "转写一个 wav 文件后退出(不开启实时采集)", "path"); + QCommandLineOption optFile("file", "转写一个 wav 文件后退出", "path"); + QCommandLineOption optStreamFile("stream-file", "以流式模式转写 wav 文件后退出", "path"); + QCommandLineOption optCliLive("cli-live", "实时转写但只用命令行输出(不开窗口)"); QCommandLineOption optThreads("threads", "推理线程数", "n", "8"); QCommandLineOption optMaxTokens("max-tokens", "单段最多生成 token 数", "n", "256"); - QCommandLineOption optWarmup("warmup", "mtmd 初始化后做一次预热编码"); - QCommandLineOption optVerbose("verbose", "输出 llama.cpp/mtmd 的详细日志"); - QCommandLineOption optStreamFile("stream-file", "以流式模式(chunk + 前缀回滚)转写 wav", "path"); QCommandLineOption optStreamChunk("stream-chunk", "流式 chunk 秒数", "sec", "2.0"); - QCommandLineOption optStreamWindow("stream-window", "流式滑窗上限秒数(0=不滑窗)", "sec", "16"); + QCommandLineOption optStreamWindow("stream-window", "滑窗上限秒数(仅 window 模式)", "sec", "16"); + QCommandLineOption optStreamSegment("stream-segment", "单段最长秒数(0=不限)", "sec", "30"); QCommandLineOption optUnfixedTokens("unfixed-tokens", "前缀回滚丢弃的 token 数", "n", "5"); QCommandLineOption optUnfixedChunks("unfixed-chunks", "冷启动不带前缀的 chunk 数", "n", "2"); - QCommandLineOption optStreamMode("stream-mode", "流式后端: incremental(增量 KV 复用)或 window(重编码整个窗口)", "mode", "incremental"); - QCommandLineOption optStreamSegment("stream-segment", "单段最长秒数,超过则提交并重置上下文(0=不限)", "sec", "30"); + QCommandLineOption optStreamMode("stream-mode", "流式后端: incremental 或 window", "mode", "incremental"); + QCommandLineOption optVerbose("verbose", "输出 llama.cpp/mtmd 的详细日志"); + QCommandLineOption optScreenshot("screenshot", "启动后自动截图并退出(验证渲染用)", "path"); + QCommandLineOption optScreenshotDelay("screenshot-delay", "截图前等待秒数", "sec", "6"); + QCommandLineOption optOpacity("opacity", "字幕背景不透明度(20-100,百分比)", "n", "84"); parser.addOption(optModel); parser.addOption(optMmproj); parser.addOption(optFile); + parser.addOption(optStreamFile); + parser.addOption(optCliLive); parser.addOption(optThreads); parser.addOption(optMaxTokens); - parser.addOption(optWarmup); - parser.addOption(optVerbose); - parser.addOption(optStreamFile); parser.addOption(optStreamChunk); parser.addOption(optStreamWindow); + parser.addOption(optStreamSegment); parser.addOption(optUnfixedTokens); parser.addOption(optUnfixedChunks); parser.addOption(optStreamMode); - parser.addOption(optStreamSegment); - parser.process(app); + parser.addOption(optVerbose); + parser.addOption(optScreenshot); + parser.addOption(optScreenshotDelay); + parser.addOption(optOpacity); + parser.process(*app); lc::AsrOptions asrOpt; asrOpt.modelPath = parser.value(optModel).toStdString(); asrOpt.mmprojPath = parser.value(optMmproj).toStdString(); asrOpt.threads = parser.value(optThreads).toInt(); asrOpt.maxTokens = parser.value(optMaxTokens).toInt(); - asrOpt.warmup = parser.isSet(optWarmup); asrOpt.verbose = parser.isSet(optVerbose); if (asrOpt.modelPath.empty() || asrOpt.mmprojPath.empty()) { @@ -356,31 +258,31 @@ int main(int argc, char ** argv) return 1; } + lc::StreamOptions sop; + sop.chunkSec = parser.value(optStreamChunk).toDouble(); + sop.windowSec = parser.value(optStreamWindow).toDouble(); + sop.maxUtteranceSec = parser.value(optStreamSegment).toDouble(); + sop.unfixedTokenNum = parser.value(optUnfixedTokens).toInt(); + sop.unfixedChunkNum = parser.value(optUnfixedChunks).toInt(); + sop.maxTokensPerStep = asrOpt.maxTokens; + sop.incremental = parser.value(optStreamMode) != QLatin1String("window"); + lc::Qwen3Asr asr; std::string err; - const auto tl0 = std::chrono::steady_clock::now(); + const auto t0 = std::chrono::steady_clock::now(); if (!asr.load(asrOpt, &err)) { std::fprintf(stderr, "加载失败: %s\n", err.c_str()); return 1; } std::fprintf(stderr, "模型加载完成: %.2fs (采样率 %d)\n", - std::chrono::duration(std::chrono::steady_clock::now() - tl0).count(), + std::chrono::duration(std::chrono::steady_clock::now() - t0).count(), asr.sampleRate()); - if (parser.isSet(optFile)) { - return runFile(asr, parser.value(optFile)); - } - - if (parser.isSet(optStreamFile)) { - lc::StreamOptions sop; - sop.chunkSec = parser.value(optStreamChunk).toDouble(); - sop.windowSec = parser.value(optStreamWindow).toDouble(); - sop.unfixedTokenNum = parser.value(optUnfixedTokens).toInt(); - sop.unfixedChunkNum = parser.value(optUnfixedChunks).toInt(); - sop.incremental = parser.value(optStreamMode) != QLatin1String("window"); - sop.maxUtteranceSec = parser.value(optStreamSegment).toDouble(); - return runStreamFile(asr, parser.value(optStreamFile), sop); - } - - return runLive(asr); + if (parser.isSet(optFile)) return runFile(asr, parser.value(optFile)); + if (parser.isSet(optStreamFile)) return runStreamFile(asr, parser.value(optStreamFile), sop); + if (parser.isSet(optCliLive)) return runCliLive(asr, sop); + return runGui(asr, sop, + parser.value(optScreenshot), + parser.value(optScreenshotDelay).toInt(), + parser.value(optOpacity).toInt()); } diff --git a/src/streamingasr.cpp b/src/streamingasr.cpp index 4aa918d..225938d 100644 --- a/src/streamingasr.cpp +++ b/src/streamingasr.cpp @@ -50,12 +50,33 @@ void StreamingAsr::feed(const float * pcm, size_t n, void StreamingAsr::flush(const std::function & onStep) { - if (pending_.empty()) return; + if (!pending_.empty()) { + std::vector chunk; + chunk.swap(pending_); + // 流结束:没有更多音频可以修正尾部了,这一步全部提交 + runStep(chunk, true, onStep); + return; + } - std::vector chunk; - chunk.swap(pending_); - // 流结束:没有更多音频可以修正尾部了,这一步全部提交 - runStep(chunk, true, onStep); + // 没有待处理音频:直接把当前已稳定文本提交为最终 + if (committedRaw_.empty()) return; + + historyText_ += parseAsrOutput(committedRaw_).text; + committedRaw_.clear(); + + Step s; + s.chunkIndex = chunkIndex_; + s.language = language_; + s.ok = true; + s.full = historyText_; + s.committed = historyText_; + if (onStep) onStep(s); + + // 收束这一段 + asr_.streamReset(); + buffer_.clear(); + totalSeconds_ = 0.0; + chunkIndex_ = 0; } void StreamingAsr::runStep(const std::vector & audio, bool finalStep, @@ -117,11 +138,17 @@ void StreamingAsr::runStep(const std::vector & audio, bool finalStep, historyText_ += pSeg.text; s.full = historyText_; s.committed = historyText_; + s.tail.clear(); } else { // 最后 unfixedTokenNum 个 token 视为不稳定,不提交也不回喂 committedRaw_ = asr_.dropLastTokens(s.rawFull, opt_.unfixedTokenNum); - s.full = historyText_ + pSeg.text; - s.committed = historyText_ + parseAsrOutput(committedRaw_).text; + const std::string committedSeg = parseAsrOutput(committedRaw_).text; + s.full = historyText_ + pSeg.text; + s.committed = historyText_ + committedSeg; + // 不稳定尾部 = 本段文本去掉已稳定的那部分 + s.tail = s.full.size() > s.committed.size() + ? s.full.substr(s.committed.size()) + : std::string(); } s.language = language_; } diff --git a/src/streamingasr.h b/src/streamingasr.h index 02c731f..df1cf66 100644 --- a/src/streamingasr.h +++ b/src/streamingasr.h @@ -37,6 +37,7 @@ public: int chunkIndex = 0; std::string language; std::string committed; // 稳定部分 + std::string tail; // 不稳定尾部(committed 之后的部分) std::string full; // 含不稳定尾部 std::string rawFull; // 未 trim 的完整文本(含 language 头) double windowSeconds = 0.0; @@ -51,7 +52,8 @@ public: // 喂入 16k 单声道音频;每凑够 chunkSec 就跑一步并回调 void feed(const float * pcm, size_t n, const std::function & onStep); - // 处理不足一块的残留音频,并作为最后一步提交全部文本 + // 处理不足一块的残留音频,并作为最后一步提交全部文本。 + // 若没有待处理音频,也把当前已稳定文本提交为最终(静音分段时会用到)。 void flush(const std::function & onStep); // 结束当前这段(例如检测到静音):清空 KV 与状态,下一段重新开始