diff --git a/CMakeLists.txt b/CMakeLists.txt index b3243cb4fc..1a55eace73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -753,6 +753,7 @@ set(CORE_SOURCES src/core/SpecbleachFilter.cpp src/core/CwDecoder.cpp src/core/DeepCwEngine.cpp # neural CW decoder backend (ONNX; inert without HAVE_ONNX) + src/core/DeepCwCommitter.cpp # DeepCW sliding window + time-anchored commit src/core/CwCallsignSpotter.cpp src/core/CallsignInfo.cpp src/core/QrzClient.cpp @@ -2791,3 +2792,14 @@ else() ) endif() + +# DeepCW offline replay harness (RFC #4817 regression take): not built by default. +if(ORT_FOUND) + add_executable(deepcw_replay EXCLUDE_FROM_ALL + tools/deepcw_replay.cpp src/core/DeepCwEngine.cpp src/core/DeepCwCommitter.cpp + src/core/Resampler.cpp) + target_compile_definitions(deepcw_replay PRIVATE HAVE_ONNX) + target_include_directories(deepcw_replay PRIVATE src + ${CMAKE_SOURCE_DIR}/third_party/r8brain ${ORT_INCLUDE_DIRS}) + target_link_libraries(deepcw_replay PRIVATE Qt6::Widgets ${ORT_LIBRARIES}) +endif() diff --git a/src/core/CwDecoder.cpp b/src/core/CwDecoder.cpp index 22f5ee0fe9..1da56dad4c 100644 --- a/src/core/CwDecoder.cpp +++ b/src/core/CwDecoder.cpp @@ -1,5 +1,6 @@ #include "CwDecoder.h" #include "LogManager.h" +#include "DeepCwCommitter.h" #include "DeepCwEngine.h" #include "Resampler.h" #include "ggmorse/ggmorse.h" @@ -268,37 +269,31 @@ void CwDecoder::decodeLoop() qCDebug(lcDsp) << "CwDecoder: decode loop exiting, total frames:" << feedCount; } -// DeepCW (neural) worker loop. The model is a whole-window CTC decoder trained -// on 5-20 s clips, so we accumulate a rolling audio segment and re-decode it as -// it grows, emitting only the newly-decoded suffix (the decode of a longer clip -// is normally a prefix-extension of the shorter one). Near the model's 20 s cap -// we finalize the segment and start fresh so inference stays in-distribution. -// -// feedAudio() has downmixed the RX audio to mono float32 @24 kHz into m_ringBuf; -// here we drain it, resample to the model's 3200 Hz with an anti-aliased r8brain -// SRC (a 7.5x decimation — a naive drop/linear resample would fold energy into -// the 400-1200 Hz analysis band), and grow a rolling 3200 Hz segment that we -// re-decode as it lengthens. The SRC stays continuous across segment resets -// (the audio stream is continuous even though the analysis window restarts). -// Prototype heuristics — a later revision can add overlap-merge and silence -// segmentation. +// DeepCW (neural) worker loop. feedAudio() has downmixed the RX audio to mono +// float32 @24 kHz into m_ringBuf; here we drain it, resample to the model's +// 3200 Hz with an anti-aliased r8brain SRC (a 7.5x decimation — a naive +// drop/linear resample would fold energy into the 400-1200 Hz analysis band), +// and hand it to DeepCwCommitter: a sliding window re-decoded every 2 s whose +// characters are shown only once they are holdSec behind the live edge, so the +// model's full-context reading reaches the panel instead of its first guess at +// the ragged end of a short window, and no hard reset cuts words at a seam. +// holdSec defaults to 5 s; AETHER_DEEPCW_HOLD_S overrides it (local bench knob). void CwDecoder::decodeLoopDeep() { - constexpr int kRate = DeepCwEngine::kModelSampleRate; // 3200 Hz (post-resample) - const size_t kMinDecode = kRate * 5; // model floor: 5 s - const size_t kHopSamples = kRate * 2; // re-decode every ~2 s of new audio - const size_t kMaxSamples = kRate * 15; // finalize before the 20 s cap (headroom) + constexpr int kRate = DeepCwEngine::kModelSampleRate; // 3200 Hz (post-resample) // Anti-aliased 24k -> 3200 Hz SRC (r8brain via the in-tree wrapper). Owned by // and used only on this worker thread, so its non-thread-safety is moot. Resampler resampler(24000.0, static_cast(kRate)); - std::vector seg; // accumulated audio at 3200 Hz - seg.reserve(kMaxSamples + kRate); - std::string emitted; // text already emitted for the current segment - size_t lastDecodeSamples = 0; + double holdSec = 5.0; + bool holdOk = false; + const double envHold = qEnvironmentVariable("AETHER_DEEPCW_HOLD_S").toDouble(&holdOk); + if (holdOk && envHold >= 1.0 && envHold <= 14.0) holdSec = envHold; + DeepCwCommitter committer(holdSec); - qCDebug(lcDsp) << "CwDecoder: DeepCW loop running, modelLoaded:" << m_deepLoaded.load(); + qCDebug(lcDsp) << "CwDecoder: DeepCW loop running, modelLoaded:" << m_deepLoaded.load() + << "hold" << committer.holdSec() << "s window" << committer.windowSec() << "s"; while (m_running) { // Drain the handoff ring (mono float32 @24k) and resample to 3200 Hz. @@ -312,53 +307,22 @@ void CwDecoder::decodeLoopDeep() m_ringBuf.clear(); } } - if (!in24k.empty()) { + if (!in24k.empty() && m_deepLoaded && m_deepcw) { const QByteArray out = resampler.process(in24k.data(), static_cast(in24k.size())); const auto* r = reinterpret_cast(out.constData()); - const int m = out.size() / static_cast(sizeof(float)); - seg.insert(seg.end(), r, r + m); - } - - const bool haveMin = seg.size() >= kMinDecode; - const bool grewEnough = seg.size() >= lastDecodeSamples + kHopSamples; - - if (m_deepLoaded && m_deepcw && haveMin && grewEnough) { - float conf = 1.0f; - float pitchHz = 0.0f; - const std::string text = m_deepcw->decode(seg, kRate, &conf, &pitchHz); - lastDecodeSamples = seg.size(); - // Map mean CTC confidence to the panel's cost convention (lower = - // better) so the Sensitivity slider filters shaky neural decodes. - const float cost = 1.0f - conf; + const auto m = static_cast(out.size() / static_cast(sizeof(float))); + const DeepCwCommitter::Result res = committer.push(r, m, *m_deepcw); // Publish the dominant-tone pitch (no speed estimate for a CTC model) // so zero-beat and the pitch readout work in neural mode too. - if (pitchHz > 0.0f) { - m_pitch = pitchHz; - emit statsUpdated(pitchHz, 0.0f); + if (res.decoded && res.pitchHz > 0.0f) { + m_pitch = res.pitchHz; + emit statsUpdated(res.pitchHz, 0.0f); } - - // Emit the suffix beyond what we've shown when the new decode extends - // the old as a prefix; on a divergent revision, silently adopt the new - // baseline (a rare correction may drop/duplicate a few chars — accepted - // for the prototype). - if (text.size() >= emitted.size() - && text.compare(0, emitted.size(), emitted) == 0) { - const std::string delta = text.substr(emitted.size()); - if (!delta.empty()) { - emit textDecoded(QString::fromStdString(delta), cost); - emitted = text; - } - } else { - emitted = text; - } - } - - // Finalize near the model's max window and start a fresh segment. - if (seg.size() >= kMaxSamples) { - seg.clear(); - emitted.clear(); - lastDecodeSamples = 0; + // Map mean CTC confidence to the panel's cost convention (lower = + // better) so the Sensitivity slider filters shaky neural decodes. + if (!res.text.empty()) + emit textDecoded(QString::fromStdString(res.text), 1.0f - res.meanConf); } QThread::msleep(200); diff --git a/src/core/DeepCwCommitter.cpp b/src/core/DeepCwCommitter.cpp new file mode 100644 index 0000000000..bb8ea18a0d --- /dev/null +++ b/src/core/DeepCwCommitter.cpp @@ -0,0 +1,103 @@ +#include "DeepCwCommitter.h" + +#include + +namespace AetherSDR { + +namespace { +constexpr int kRate = DeepCwEngine::kModelSampleRate; +constexpr double kFrameSec = static_cast(DeepCwEngine::kHopLength) / kRate; +constexpr int kSnapBlankFrames = 3; +} // namespace + +DeepCwCommitter::DeepCwCommitter(double holdSec, double leftContextSec, double hopSec) + : m_hold(holdSec) + , m_left(leftContextSec) + , m_hop(hopSec) + // Stay inside the model's trained 5-20 s window range. + , m_window(std::min(DeepCwEngine::kMaxWindowSec, + std::max(15.0, holdSec + leftContextSec + hopSec))) + , m_keep(std::min(m_window - hopSec, holdSec + leftContextSec)) +{ +} + +void DeepCwCommitter::reset() +{ + m_buf.clear(); + m_bufStart = 0; + m_lastDecodeEnd = 0; + m_tCommit = -1.0; + m_tLastChar = -1.0; + m_lastOut = 0; +} + +DeepCwCommitter::Result DeepCwCommitter::push(const float* audio3200, std::size_t n, + const DeepCwEngine& eng) +{ + Result r; + if (n > 0) m_buf.insert(m_buf.end(), audio3200, audio3200 + n); + const std::size_t absEnd = m_bufStart + m_buf.size(); + if (m_buf.size() >= static_cast(kRate * DeepCwEngine::kMinWindowSec) + && absEnd >= m_lastDecodeEnd + static_cast(m_hop * kRate)) { + m_lastDecodeEnd = absEnd; + r = decodeAndCommit(eng, false); + } + if (m_buf.size() >= static_cast(m_window * kRate)) { + const std::size_t drop = m_buf.size() - static_cast(m_keep * kRate); + m_buf.erase(m_buf.begin(), m_buf.begin() + static_cast(drop)); + m_bufStart += drop; + } + return r; +} + +DeepCwCommitter::Result DeepCwCommitter::flush(const DeepCwEngine& eng) +{ + return decodeAndCommit(eng, true); +} + +DeepCwCommitter::Result DeepCwCommitter::decodeAndCommit(const DeepCwEngine& eng, bool flushAll) +{ + Result r; + int frames = 0; + const std::vector lp = eng.inferLogProbs(m_buf, &frames, &r.pitchHz); + if (frames == 0) return r; + r.decoded = true; + + std::vector blank; + const std::vector em = eng.greedyEmissions(lp.data(), frames, &blank); + + const double t0 = static_cast(m_bufStart) / kRate; + const double nowT = static_cast(m_bufStart + m_buf.size()) / kRate; + double cutoff = 1e18; + if (!flushAll) { + // Snap back to the latest frame <= (now - hold) that ends a blank run. + int f = std::min(frames - 1, static_cast((nowT - m_hold - t0) / kFrameSec)); + while (f >= kSnapBlankFrames + && !(blank[f] && blank[f - 1] && blank[f - 2])) --f; + cutoff = t0 + f * kFrameSec; + } + + double confSum = 0.0; + int confN = 0; + for (const auto& e : em) { + const double t = t0 + e.frame * kFrameSec; + // Word spaces are emitted inside the silent gap the cutoff snaps into, + // so a space may land just behind tCommit on the next decode: accept it + // anywhere after the last committed letter. + const double from = (e.ch == ' ') ? m_tLastChar : m_tCommit; + if (t <= from || t > cutoff) continue; + if (e.ch == ' ' && (m_lastOut == 0 || m_lastOut == ' ')) continue; + r.text.push_back(e.ch); + m_lastOut = e.ch; + if (e.ch != ' ') { + m_tLastChar = t; + confSum += e.conf; + ++confN; + } + } + if (confN > 0) r.meanConf = static_cast(confSum / confN); + if (cutoff > m_tCommit) m_tCommit = cutoff; + return r; +} + +} // namespace AetherSDR diff --git a/src/core/DeepCwCommitter.h b/src/core/DeepCwCommitter.h new file mode 100644 index 0000000000..3f82318e98 --- /dev/null +++ b/src/core/DeepCwCommitter.h @@ -0,0 +1,59 @@ +#pragma once + +// DeepCW streaming front: sliding analysis window + time-anchored commit. +// +// The model is a whole-window CTC decoder, and its reading of a character +// keeps improving while more audio arrives after it. So each hop the whole +// window is re-decoded, but a character is committed (shown) only once it was +// emitted more than holdSec behind the live edge; everything newer stays +// provisional and is re-read on the next hop. The commit point is snapped back +// into a run of blank frames so no character straddles it. When the window +// reaches its maximum, the oldest audio is dropped but holdSec + leftContextSec +// is kept, so there is no hard reset and no character or word is cut at a seam. +// +// Qt-free (std + DeepCwEngine) so the offline replay tool runs exactly this code. + +#include "DeepCwEngine.h" + +#include +#include +#include + +namespace AetherSDR { + +class DeepCwCommitter { +public: + explicit DeepCwCommitter(double holdSec = 5.0, double leftContextSec = 3.0, + double hopSec = 2.0); + + struct Result { + bool decoded{false}; // a model decode ran on this push + std::string text; // newly committed characters (may be empty) + float meanConf{1.0f}; // mean posterior of the committed characters + float pitchHz{0.0f}; // dominant tone of the decoded window + }; + + // Append model-rate (3200 Hz) mono audio; decodes when a hop has elapsed. + Result push(const float* audio3200, std::size_t n, const DeepCwEngine& eng); + + // Commit everything still provisional (offline end-of-file use). + Result flush(const DeepCwEngine& eng); + + void reset(); + + double holdSec() const { return m_hold; } + double windowSec() const { return m_window; } + +private: + Result decodeAndCommit(const DeepCwEngine& eng, bool flushAll); + + double m_hold, m_left, m_hop, m_window, m_keep; + std::vector m_buf; + std::size_t m_bufStart{0}; // absolute sample index of m_buf[0] + std::size_t m_lastDecodeEnd{0}; + double m_tCommit{-1.0}; // committed up to this time (s) + double m_tLastChar{-1.0}; // emission time of the last committed letter + char m_lastOut{0}; +}; + +} // namespace AetherSDR diff --git a/src/core/DeepCwEngine.cpp b/src/core/DeepCwEngine.cpp index 35c70f66c7..7bc5316a54 100644 --- a/src/core/DeepCwEngine.cpp +++ b/src/core/DeepCwEngine.cpp @@ -189,6 +189,84 @@ std::string DeepCwEngine::ctcDecode(const float* logProbs, int frames, float* av return text; } +std::vector DeepCwEngine::inferLogProbs(const std::vector& audio3200, int* frames, + float* pitchHz) const +{ + *frames = 0; + if (pitchHz) *pitchHz = 0.0f; +#ifdef HAVE_ONNX + if (!m_loaded || !m_session) { return {}; } + int specFrames = 0; + const std::vector spec = spectrogram(audio3200, &specFrames); + if (specFrames == 0 || spec.empty()) { return {}; } + if (pitchHz) { // same peak-energy-bin estimate as decode() + const double binHz = static_cast(kModelSampleRate) / kFftLength; + const int startBin = static_cast(std::ceil(kMinFreqHz / binHz)); + int peak = 0; double peakE = -1.0; + for (int k = 0; k < kFreqBins; ++k) { + double e = 0.0; + for (int f = 0; f < specFrames; ++f) e += spec[static_cast(f) * kFreqBins + k]; + if (e > peakE) { peakE = e; peak = k; } + } + *pitchHz = static_cast((startBin + peak) * binHz); + } + try { + const std::array shape{1, 1, specFrames, kFreqBins}; + Ort::Value input = Ort::Value::CreateTensor( + m_memInfo, const_cast(spec.data()), spec.size(), + shape.data(), shape.size()); + const char* inputNames[] = {"spectrogram"}; + const char* outputNames[] = {"log_probs"}; + auto outputs = m_session->Run(Ort::RunOptions{nullptr}, + inputNames, &input, 1, outputNames, 1); + const float* lp = outputs[0].GetTensorData(); + const auto outShape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); + const int outFrames = outShape.size() >= 2 + ? static_cast(outShape[outShape.size() - 2]) : specFrames; + *frames = outFrames; + return std::vector(lp, lp + static_cast(outFrames) * kNumClasses); + } catch (const Ort::Exception& ex) { + std::fprintf(stderr, "DeepCwEngine: inference error: %s\n", ex.what()); + return {}; + } +#else + (void)audio3200; + return {}; +#endif +} + +std::vector DeepCwEngine::greedyEmissions( + const float* logProbs, int frames, std::vector* blankFrame) const +{ + // Same greedy rule as ctcDecode(): argmax per frame, blank resets, a new + // character on each change of non-blank argmax. Adds the frame index and + // the softmax posterior of the chosen class. + std::vector out; + if (blankFrame) blankFrame->assign(static_cast(std::max(frames, 0)), 0); + int previous = -1; + for (int t = 0; t < frames; ++t) { + const float* row = logProbs + static_cast(t) * kNumClasses; + int best = 0; + float bestVal = row[0]; + for (int c = 1; c < kNumClasses; ++c) { + if (row[c] > bestVal) { bestVal = row[c]; best = c; } + } + if (best == kBlankIndex) { + if (blankFrame) (*blankFrame)[static_cast(t)] = 1; + previous = -1; + continue; + } + if (best != previous) { + double sumExp = 0.0; + for (int c = 0; c < kNumClasses; ++c) + sumExp += std::exp(static_cast(row[c]) - bestVal); + out.push_back({kChars[best], t, static_cast(1.0 / sumExp)}); + } + previous = best; + } + return out; +} + std::string DeepCwEngine::decode(const std::vector& audio, int sampleRateHz, float* avgConfidence, float* pitchHz) const { diff --git a/src/core/DeepCwEngine.h b/src/core/DeepCwEngine.h index 27089ee7c9..6dd99ce4bf 100644 --- a/src/core/DeepCwEngine.h +++ b/src/core/DeepCwEngine.h @@ -8,6 +8,7 @@ #include #include +#include #include #ifdef HAVE_ONNX @@ -61,6 +62,28 @@ class DeepCwEngine { std::string decode(const std::vector& audio, int sampleRateHz, float* avgConfidence = nullptr, float* pitchHz = nullptr) const; + // Run the same spectrogram + inference as decode() on a 3200 Hz window and + // return the raw log_probs, row-major [*frames x kNumClasses]. Empty on error. + // Used by DeepCwCommitter, which needs per-character timing. + // pitchHz (optional) gets the same dominant-tone estimate decode() gives. + std::vector inferLogProbs(const std::vector& audio3200, int* frames, + float* pitchHz = nullptr) const; + + // One greedy-CTC emission: the character, the frame it was emitted at + // (frame f ~ f * kHopLength / kModelSampleRate seconds into the window) and + // the softmax posterior of its class at that frame. + struct Emission { char ch; int frame; float conf; }; + // Greedy CTC over log_probs [frames x kNumClasses], same rule as + // ctcDecode(), keeping per-character timing. blankFrame (optional) gets 1 + // for every frame whose argmax is the CTC blank. + std::vector greedyEmissions(const float* logProbs, int frames, + std::vector* blankFrame = nullptr) const; + + // Greedy CTC decode of log_probs [frames, kNumClasses] -> text. When + // avgConfidence is non-null, sets it to the mean softmax probability of the + // argmax class across the timesteps that emitted a character. + std::string ctcDecode(const float* logProbs, int frames, float* avgConfidence) const; + // Model contract constants (from model.onnx.json). static constexpr int kModelSampleRate = 3200; static constexpr int kFftLength = 256; @@ -78,11 +101,6 @@ class DeepCwEngine { // (time-major, freq inner), from 3200 Hz mono audio. *frames set on return. std::vector spectrogram(const std::vector& audio3200, int* frames) const; - // Greedy CTC decode of log_probs [frames, kNumClasses] -> text. When - // avgConfidence is non-null, sets it to the mean softmax probability of the - // argmax class across the timesteps that emitted a character. - std::string ctcDecode(const float* logProbs, int frames, float* avgConfidence) const; - bool m_loaded{false}; #ifdef HAVE_ONNX Ort::Env m_env; diff --git a/src/gui/CwNeuralApplet.cpp b/src/gui/CwNeuralApplet.cpp index 4923c213f7..961d15a32b 100644 --- a/src/gui/CwNeuralApplet.cpp +++ b/src/gui/CwNeuralApplet.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include #include @@ -64,9 +66,15 @@ void CwNeuralApplet::appendText(const QString& text, float cost) else if (cost < 0.60f) color = "#ff9020"; else color = "#ff4040"; - m_text->moveCursor(QTextCursor::End); - m_text->insertHtml(QString("%2") - .arg(color, clean.toHtmlEscaped())); + // Plain-text insert with a colour format, not insertHtml(): the HTML + // parser drops a fragment's leading whitespace, so a suffix that began + // with its word space glued onto the previous word (measured, Qt 6.8.3). + QTextCursor cur = m_text->textCursor(); + cur.movePosition(QTextCursor::End); + QTextCharFormat fmt; + fmt.setForeground(QColor(color)); + cur.insertText(clean, fmt); + m_text->setTextCursor(cur); m_text->moveCursor(QTextCursor::End); } diff --git a/src/gui/PanadapterApplet.cpp b/src/gui/PanadapterApplet.cpp index b768dabf54..c2c521d3d4 100644 --- a/src/gui/PanadapterApplet.cpp +++ b/src/gui/PanadapterApplet.cpp @@ -26,6 +26,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -805,14 +808,21 @@ void PanadapterApplet::appendCwText(const QString& text, float cost) else if (cost < 0.60f) color = "#ff9020"; else color = "#ff4040"; - m_cwText->moveCursor(QTextCursor::End); + // Plain-text insert with a colour format, not insertHtml(): the HTML + // parser drops a fragment's leading whitespace (and a whitespace-only + // fragment entirely), which glued words together whenever a decode + // chunk began with its word space (measured, Qt 6.8.3). + QTextCursor cur = m_cwText->textCursor(); + cur.movePosition(QTextCursor::End); // Switching back from TX → RX inserts a separator space so the [TX] // burst and the following RX text don't run together (#2417). if (m_lastCwTextSource == CwTextSource::Tx) - m_cwText->insertHtml(QStringLiteral(" ")); + cur.insertText(QStringLiteral(" ")); m_lastCwTextSource = CwTextSource::Rx; - m_cwText->insertHtml(QString("%2") - .arg(color, clean.toHtmlEscaped())); + QTextCharFormat fmt; + fmt.setForeground(QColor(color)); + cur.insertText(clean, fmt); + m_cwText->setTextCursor(cur); m_cwText->moveCursor(QTextCursor::End); emit cwRxTextDisplayed(clean); diff --git a/tools/deepcw_replay.cpp b/tools/deepcw_replay.cpp new file mode 100644 index 0000000000..b59ee673b1 --- /dev/null +++ b/tools/deepcw_replay.cpp @@ -0,0 +1,183 @@ +// deepcw_replay — replay a recorded CW WAV through the DeepCW engine offline. +// +// The regression harness asked for on RFC #4817: run a recorded take through +// the decode paths and compare the text they produce on identical audio. +// +// deepcw_replay +// -window.txt each 15 s window decoded once, whole (what the +// model reads with full context; no streaming) +// -grow.txt the pre-DeepCwCommitter loop (grow to 15 s, +// prefix-extension emission, hard reset) as the +// panel showed it via QTextEdit::insertHtml +// deepcw_replay commit [holdSec] +// -commit-H.txt the shipped path: DeepCwCommitter fed +// in 200 ms drains, exactly as decodeLoopDeep() +// +// Input: PCM16 WAV at 24 kHz, mono or stereo (the QSO recorder's format). + +#include "core/DeepCwCommitter.h" +#include "core/DeepCwEngine.h" +#include "core/Resampler.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using AetherSDR::DeepCwCommitter; +using AetherSDR::DeepCwEngine; +using AetherSDR::Resampler; + +namespace { + +constexpr int kRate = DeepCwEngine::kModelSampleRate; +constexpr size_t kDrain = 4800; // 200 ms of 24 kHz, the worker's drain cadence + +bool readWav(const char* path, std::vector& mono24k) +{ + std::ifstream f(path, std::ios::binary); + char riff[12]; + if (!f.read(riff, 12) || std::memcmp(riff, "RIFF", 4) || std::memcmp(riff + 8, "WAVE", 4)) return false; + uint16_t channels = 0, bits = 0, fmt = 0; + uint32_t rate = 0; + while (f) { + char id[4]; + uint32_t len = 0; + f.read(id, 4); + f.read(reinterpret_cast(&len), 4); + if (!f) break; + if (!std::memcmp(id, "fmt ", 4)) { + std::vector b(len); + f.read(b.data(), len); + std::memcpy(&fmt, b.data(), 2); + std::memcpy(&channels, b.data() + 2, 2); + std::memcpy(&rate, b.data() + 4, 4); + std::memcpy(&bits, b.data() + 14, 2); + } else if (!std::memcmp(id, "data", 4)) { + if (fmt != 1 || bits != 16 || rate != 24000 || channels < 1 || channels > 2) { + std::fprintf(stderr, "need PCM16 24 kHz mono/stereo\n"); + return false; + } + std::vector s(len / 2); + f.read(reinterpret_cast(s.data()), static_cast(s.size() * 2)); + const size_t n = s.size() / channels; + mono24k.resize(n); + for (size_t i = 0; i < n; ++i) { // same downmix as CwDecoder::feedAudio + const float l = s[i * channels] / 32768.0f; + const float r = channels == 2 ? s[i * channels + 1] / 32768.0f : l; + mono24k[i] = (l + r) * 0.5f; + } + return true; + } else { + f.seekg(len + (len & 1), std::ios::cur); + } + } + return false; +} + +// 24 kHz -> 3200 Hz in drain-sized blocks, as the worker does. +std::vector> resampleInDrains(const std::vector& in24k) +{ + Resampler rs(24000.0, kRate); + std::vector> out; + for (size_t off = 0; off < in24k.size(); off += kDrain) { + const int n = static_cast(std::min(kDrain, in24k.size() - off)); + const QByteArray o = rs.process(in24k.data() + off, n); + const auto* r = reinterpret_cast(o.constData()); + out.emplace_back(r, r + o.size() / int(sizeof(float))); + } + return out; +} + +bool writeText(const std::string& path, const std::string& header, const std::string& body) +{ + std::FILE* f = std::fopen(path.c_str(), "w"); + if (!f) return false; + std::fprintf(f, "%s\n%s\n", header.c_str(), body.c_str()); + std::fclose(f); + return true; +} + +} // namespace + +int main(int argc, char** argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + if (argc < 4) { + std::fprintf(stderr, "usage: %s [commit [holdSec]]\n", argv[0]); + return 2; + } + const std::string out = argv[3]; + DeepCwEngine eng; + if (!eng.loadModel(argv[1])) return 1; + std::vector in24k; + if (!readWav(argv[2], in24k)) { std::fprintf(stderr, "cannot read %s\n", argv[2]); return 1; } + const auto drains = resampleInDrains(in24k); + + if (argc > 4 && std::string(argv[4]) == "commit") { + const double hold = argc > 5 ? std::atof(argv[5]) : 5.0; + DeepCwCommitter c(hold); + std::string text; + for (const auto& d : drains) text += c.push(d.data(), d.size(), eng).text; + text += c.flush(eng).text; + char name[48]; + std::snprintf(name, sizeof name, "-commit-H%.0f.txt", hold); + writeText(out + name, "hold=" + std::to_string(hold), text); + std::printf("commit hold=%.1f s: %zu chars\n", hold, text.size()); + return 0; + } + + // Whole 15 s windows, each decoded once. + std::vector all; + for (const auto& d : drains) all.insert(all.end(), d.begin(), d.end()); + const size_t win = static_cast(kRate) * 15; + std::string windows; + for (size_t s0 = 0; s0 + static_cast(kRate * DeepCwEngine::kMinWindowSec) <= all.size(); s0 += win) { + const std::vector seg(all.begin() + s0, all.begin() + std::min(all.size(), s0 + win)); + windows += eng.decode(seg, kRate) + " | "; + } + writeText(out + "-window.txt", "15 s windows, each decoded once ( | = window boundary)", windows); + + // The pre-DeepCwCommitter loop, rendered the way the panel rendered it. + const size_t minDecode = kRate * 5, hop = kRate * 2, maxSeg = kRate * 15; + std::vector seg; + std::string emitted; + size_t lastDecode = 0; + int dropped = 0; + QTextEdit panel; + for (const auto& d : drains) { + seg.insert(seg.end(), d.begin(), d.end()); + if (seg.size() >= minDecode && seg.size() >= lastDecode + hop) { + const std::string text = eng.decode(seg, kRate); + lastDecode = seg.size(); + if (text.size() >= emitted.size() && text.compare(0, emitted.size(), emitted) == 0) { + const std::string delta = text.substr(emitted.size()); + if (!delta.empty()) { + panel.moveCursor(QTextCursor::End); + panel.insertHtml(QString("%1") + .arg(QString::fromStdString(delta).toHtmlEscaped())); + emitted = text; + } + } else { + ++dropped; // divergent revision: adopted silently, never shown + emitted = text; + } + } + if (seg.size() >= maxSeg) { seg.clear(); emitted.clear(); lastDecode = 0; } + } + writeText(out + "-grow.txt", + "pre-commit loop as shown via insertHtml; divergent revisions dropped: " + std::to_string(dropped), + panel.toPlainText().toStdString()); + std::printf("window + grow views written; %d divergent revisions dropped\n", dropped); + return 0; +}