Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/deepfist-cw-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ there is no second normalized decoder in the UI. It also lowers the
activity-gate threshold from the stream default 12 to 3 so weak off-air CW is
not gated out (#5950); that value is what every user of this backend runs.
Output has no calibrated
ggmorse cost and is displayed without inventing confidence, pitch or speed.
ggmorse cost. Each letter is coloured by the model's own posterior (cost =
1 − posterior, the four ggmorse colour bands) and is never filtered by the
sensitivity slider; pitch and speed are not invented.
DeepFist output does not feed automatic callsign spotting. Other monitored
slices and speaker gain/mute do not alter the selected decoder input. This is
an audio slice tap, not an RF separation claim.
Expand Down
12 changes: 12 additions & 0 deletions docs/theming/canonical-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ themes can preserve contrast without bypassing the theme system.
| `color.meter.gainReduction` | `#f2c14e` | GR meter bar |
| `color.meter.bar.fill` | `#405060` | meter bar inactive fill (~31 refs) |

### CW decoder confidence (specialised — paint code only)

DeepFist letters, by cost = 1 − the model's posterior (< 0.15 / < 0.35 / < 0.60 / else). Light values are at least
4.5 : 1 on `color.background.0`.

| Token | Dark | Light |
|---|---|---|
| `color.cw.confidence.high` | `#00ff88` | `{color.green.500}` (`#1a8040`) |
| `color.cw.confidence.medium` | `#e0e040` | `#737313` |
| `color.cw.confidence.fair` | `#ff9020` | `#ae5700` |
| `color.cw.confidence.low` | `#ff4040` | `{color.red.500}` (`#c02020`) |

### Spectrum / waterfall (specialised — paint code only)

| Token | Canonical | Notes |
Expand Down
6 changes: 6 additions & 0 deletions resources/themes/default-dark.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@
"bloom": { "blue": "#293aa7ff", "teal": "#1a7bf2dc" },
"grid": "#0978aad2"
},
"cw": {
"confidence.high": "#00ff88",
"confidence.medium": "#e0e040",
"confidence.fair": "#ff9020",
"confidence.low": "#ff4040"
},
"meter": {
"crst": "{color.red.500}",
"rms": "{color.blue.500}",
Expand Down
6 changes: 6 additions & 0 deletions resources/themes/default-light.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@
"bloom": { "blue": "#140b6bc4", "teal": "#0f12a08c" },
"grid": "#0a1a2a3a"
},
"cw": {
"confidence.high": "{color.green.500}",
"confidence.medium": "#737313",
"confidence.fair": "#ae5700",
"confidence.low": "{color.red.500}"
},
"meter": {
"crst": "{color.red.500}",
"rms": "{color.blue.500}",
Expand Down
4 changes: 4 additions & 0 deletions src/core/ThemeSeedGenerated.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ void ThemeManager::seedGeneratedDefaults()
m_tokens.insert("color.canvas.dots", QString("#50e6f0fa"));
m_tokens.insert("color.control.inactive", QString("#8ea8c0"));
m_tokens.insert("color.control.unavailable", QString("#3a4a5a"));
m_tokens.insert("color.cw.confidence.fair", QString("#ff9020"));
m_tokens.insert("color.cw.confidence.high", QString("#00ff88"));
m_tokens.insert("color.cw.confidence.low", QString("#ff4040"));
m_tokens.insert("color.cw.confidence.medium", QString("#e0e040"));
m_tokens.insert("color.highlight.fg", QString("#000000"));
m_tokens.insert("color.highlight.message", QString("#e58be5"));
m_tokens.insert("color.highlight.rx", QString("#379baf"));
Expand Down
82 changes: 75 additions & 7 deletions src/core/deepfist/DeepFistCommitter.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,99 @@

namespace AetherSDR {
// Worker-owned text finalization. Only the optional carry strategy retains state
// beyond the existing time watermark; no model output or audio is kept here.
// beyond the existing time watermark; no model output or audio is kept here,
// except each token's confidence from the last few seconds of windows.
class DeepFistCommitter {
public:
struct Token { int id; double seconds; QString text; };
// confidence: the model's posterior for this token in one window, 0..1.
struct Token { int id; double seconds; QString text; float confidence = 1.f; };
// One published run of text and the confidence it is shown with.
struct Piece { QString text; float confidence; };
double committed() const { return m_committed; }
std::size_t pendingCount() const { return m_pending.size(); }
QString process(const std::vector<Token>& tokens, bool gated, double end,
double settled, bool carry)
double settled, bool carry, std::vector<Piece>* pieces = nullptr)
{
QString output;
// A letter is re-decoded in every window it stays in (~3 before it
// settles). Its confidence is the mean over those sightings, one per
// earlier window. Same-token letters chained within 0.12 s in one window
// pair with an earlier window's sightings by order when the counts match;
// otherwise each takes the closest sighting within 0.12 s.
const auto publish = [&](const Token& token, bool current, std::vector<double> own) {
output += token.text;
if (!pieces) { return; }
std::sort(own.begin(), own.end());
auto first = std::find(own.begin(), own.end(), token.seconds);
if (first == own.end()) { own = {token.seconds}; first = own.begin(); }
const std::size_t rank = static_cast<std::size_t>(first - own.begin());
auto last = first;
while (first != own.begin() && *first - *(first - 1) <= 0.12) { --first; }
while (last + 1 != own.end() && *(last + 1) - *last <= 0.12) { ++last; }
const std::size_t run = static_cast<std::size_t>(last - first) + 1;
const std::size_t position = rank - static_cast<std::size_t>(first - own.begin());
double sum = current ? token.confidence : 0.0;
int count = current ? 1 : 0;
for (std::size_t i = 0; i < m_history.size();) {
const double window = m_history[i].end;
std::vector<const Sighting*> near;
for (; i < m_history.size() && m_history[i].end == window; ++i) {
const Sighting& sighting = m_history[i];
if (sighting.id == token.id && sighting.seconds >= *first - 0.12
&& sighting.seconds <= *last + 0.12) { near.push_back(&sighting); }
}
std::sort(near.begin(), near.end(),
[](const Sighting* a, const Sighting* b) { return a->seconds < b->seconds; });
const Sighting* match = nullptr;
if (near.size() == run) {
match = near[position];
} else {
for (const Sighting* sighting : near) {
const double distance = std::abs(sighting->seconds - token.seconds);
if (distance <= 0.12
&& (!match || distance < std::abs(match->seconds - token.seconds))) { match = sighting; }
}
}
if (match) { sum += match->confidence; ++count; }
}
pieces->push_back({token.text, count ? static_cast<float>(sum / count) : token.confidence});
};
// The times of one token id in the current window, or in an earlier window by its end.
const auto times = [&](int id) {
std::vector<double> result;
for (const Token& token : tokens) { if (token.id == id) { result.push_back(token.seconds); } }
return result;
};
const auto earlierTimes = [&](int id, double window) {
std::vector<double> result;
for (const Sighting& sighting : m_history) {
if (sighting.id == id && sighting.end == window) { result.push_back(sighting.seconds); }
}
return result;
};
const auto separate = [&] {
output += QLatin1Char(' ');
if (pieces) { pieces->push_back({QStringLiteral(" "), 1.f}); }
};
if (gated) {
bool pendingTail = false;
if (carry) {
for (const Pending& token : m_pending) {
if (token.seen >= 2 && token.seconds > m_committed
&& end - token.last <= 1.21) {
if (token.seconds <= settled) { output += token.text; }
if (token.seconds <= settled) { publish(token, false, earlierTimes(token.id, token.last)); }
else { pendingTail = true; }
}
}
}
// Close the word only after its confirmed tail has settled or
// expired; a separator cannot be taken back after publication.
if (!pendingTail && !m_idle) { output += QLatin1Char(' '); m_idle = true; }
if (!pendingTail && !m_idle) { separate(); m_idle = true; }
} else {
m_idle = false;
std::vector<Pending> next;
for (const Token& token : tokens) {
if (token.seconds > m_committed && token.seconds <= settled) { output += token.text; }
if (token.seconds > m_committed && token.seconds <= settled) { publish(token, true, times(token.id)); }
if (!carry || token.seconds <= std::max(m_committed, settled) || next.size() >= 128) { continue; }
auto match = m_pending.end();
double distance = 0.16;
Expand All @@ -46,12 +110,14 @@ class DeepFistCommitter {
}
}
const int seen = match == m_pending.end() ? 1 : std::min(2, match->seen + 1);
next.push_back({{token.id, token.seconds, token.text}, seen, end});
next.push_back({token, seen, end});
// One-to-one matching preserves repeated characters as separate events.
if (match != m_pending.end()) { m_pending.erase(match); }
}
m_pending = std::move(next);
for (const Token& token : tokens) { m_history.push_back({token.id, token.seconds, token.confidence, end}); }
}
std::erase_if(m_history, [&](const Sighting& sighting) { return end - sighting.end > 8.0; });
m_committed = std::max(m_committed, settled);
std::erase_if(m_pending, [&](const Pending& token) {
return token.seconds <= m_committed || end - token.last > 1.21;
Expand All @@ -60,9 +126,11 @@ class DeepFistCommitter {
}
private:
struct Pending : Token { int seen; double last; };
struct Sighting { int id; double seconds; float confidence; double end; };
double m_committed = 0;
// Starts idle: a committer that has published nothing owes no separator.
bool m_idle = true;
std::vector<Pending> m_pending;
std::vector<Sighting> m_history;
};
}
26 changes: 23 additions & 3 deletions src/core/deepfist/DeepFistStream.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,27 @@ bool DeepFistStream::hasCompletedMark(const float* samples, int count)
}
return false;
}
float DeepFistStream::spanConfidence(const float* logProbs, int frames, int classes, int frame, int id)
{
if (!logProbs || frames <= 0 || classes <= 0 || frame < 0 || frame >= frames || id < 0 || id >= classes) {
return 0.f;
}
double sum = 0;
int count = 0;
for (int t = frame; t < frames; ++t) {
const float* row = logProbs + static_cast<std::size_t>(t) * classes;
const float* best = std::max_element(row, row + classes);
if (t > frame && best - row != id) { break; }
double total = 0;
for (int c = 0; c < classes; ++c) { total += std::exp(double(row[c]) - *best); }
sum += std::exp(double(row[id]) - *best) / total;
++count;
}
return static_cast<float>(sum / count);
}
QString DeepFistStream::process(const float* samples, int count, lyra::dsp::DeepFistModel& model,
std::vector<Observation>* observations)
std::vector<Observation>* observations,
std::vector<DeepFistCommitter::Piece>* pieces)
{
if (m_failed) { return {}; }
QString result;
Expand Down Expand Up @@ -117,7 +136,8 @@ QString DeepFistStream::process(const float* samples, int count, lyra::dsp::Deep
}
const double time = end - 6.0 + decoded.frames[j] * 6.0 / frames;
const QString token = QString::fromStdString(model.tokens()[decoded.ids[j]]);
tokens.push_back({decoded.ids[j], time, token});
tokens.push_back({decoded.ids[j], time, token,
spanConfidence(m_logits.data(), frames, classes, decoded.frames[j], decoded.ids[j])});
if (observations) {
const QString decision = gated ? QStringLiteral("activity_gate")
: time <= m_committer.committed() ? QStringLiteral("past_boundary")
Expand All @@ -126,7 +146,7 @@ QString DeepFistStream::process(const float* samples, int count, lyra::dsp::Deep
}
}
}
const QString publication = m_committer.process(tokens, gated, end, settled, m_parameters.carryPending);
const QString publication = m_committer.process(tokens, gated, end, settled, m_parameters.carryPending, pieces);
result += publication;
if (observations) {
observation.publication = publication;
Expand Down
9 changes: 8 additions & 1 deletion src/core/deepfist/DeepFistStream.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,15 @@ class DeepFistStream {
};
std::vector<Token> tokens;
};
// pieces, when given, receives the published text split into runs with
// their confidence (DeepFistCommitter::Piece); the returned text is unchanged.
QString process(const float* samples, int count, lyra::dsp::DeepFistModel& model,
std::vector<Observation>* observations = nullptr);
std::vector<Observation>* observations = nullptr,
std::vector<DeepFistCommitter::Piece>* pieces = nullptr);
// Mean posterior of class `id` over the frames from `frame` while it stays
// the argmax. `logProbs` is row-major [frames][classes]; each row is
// normalized here, so raw logits and log-probabilities give the same value.
static float spanConfidence(const float* logProbs, int frames, int classes, int frame, int id);
Comment thread
jensenpat marked this conversation as resolved.
static bool hasCompletedMark(const float* samples, int count);
bool failed() const { return m_failed; }
private:
Expand Down
1 change: 1 addition & 0 deletions src/gui/MainWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,7 @@ private slots:
void cwRxModelAction();
void refreshCwRxStatus();
void appendUnscoredCwText(const QString& text);
void appendColoredCwText(const QString& text, float cost);
void refreshCwRxBackend();
#endif
CwRxModel m_cwDecoder;
Expand Down
8 changes: 8 additions & 0 deletions src/gui/MainWindow_DigitalModes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,19 @@ void MainWindow::appendUnscoredCwText(const QString& text)
m_cwDecoderApplet->appendUnscoredCwText(text);
}
}
void MainWindow::appendColoredCwText(const QString& text, float cost)
{
if (m_cwDecoderApplet && m_cwDecoder.isRunning()) {
m_cwDecoderApplet->appendColoredCwText(text, cost);
}
}
void MainWindow::refreshCwRxBackend()
{
m_cwDecoder.selectBackend(CwDecodeSettings::backend());
connect(&m_cwDecoder, &CwRxModel::unscoredTextDecoded,
this, &MainWindow::appendUnscoredCwText, Qt::UniqueConnection);
connect(&m_cwDecoder, &CwRxModel::coloredTextDecoded,
this, &MainWindow::appendColoredCwText, Qt::UniqueConnection);
connect(&m_cwDecoder, &CwRxModel::statusChanged,
this, &MainWindow::refreshCwRxStatus, Qt::UniqueConnection);
if (m_cwDecoderApplet) {
Expand Down
46 changes: 35 additions & 11 deletions src/gui/PanadapterApplet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,19 @@ int PanadapterApplet::pitchRangeHigh() const
return m_pitchRangeSlider ? m_pitchRangeSlider->high() : 700;
}

QString PanadapterApplet::cwCostColor(float cost)
{
// Color by confidence: lower cost = higher confidence
// < 0.15 green (high confidence)
// < 0.35 yellow (medium)
// < 0.60 orange (meh)
// >= 0.60 red (low confidence)
if (cost < 0.15f) { return QStringLiteral("#00ff88"); }
if (cost < 0.35f) { return QStringLiteral("#e0e040"); }
if (cost < 0.60f) { return QStringLiteral("#ff9020"); }
return QStringLiteral("#ff4040");
}

void PanadapterApplet::appendCwText(const QString& text, float cost)
{
#ifdef HAVE_DEEPFIST
Expand All @@ -835,16 +848,7 @@ void PanadapterApplet::appendCwText(const QString& text, float cost)
QString clean = text;
clean.replace('\n', ' ');

// Color by confidence: lower cost = higher confidence
// < 0.15 green (high confidence)
// < 0.35 yellow (medium)
// < 0.60 orange (meh)
// >= 0.60 red (low confidence)
QString color;
if (cost < 0.15f) color = "#00ff88";
else if (cost < 0.35f) color = "#e0e040";
else if (cost < 0.60f) color = "#ff9020";
else color = "#ff4040";
const QString color = cwCostColor(cost);

m_cwText->moveCursor(QTextCursor::End);
// Switching back from TX → RX inserts a separator space so the [TX]
Expand Down Expand Up @@ -911,7 +915,27 @@ void PanadapterApplet::appendUnscoredCwText(const QString& text)
QTextCursor cursor = m_cwText->textCursor();
cursor.insertText(clean, format);
m_cwText->moveCursor(QTextCursor::End);
// DeepFist does not provide a calibrated ggmorse confidence score.
// For a backend that reports no per-letter score.
}
void PanadapterApplet::appendColoredCwText(const QString& text, float cost)
{
// The cost is 1 - the backend's own per-letter posterior. It picks the
// color (ggmorse's four bands, as theme tokens) and is never compared
// with the Sens threshold, which is on ggmorse's scale.
QString clean = text;
clean.replace('\n', ' ');
m_cwText->moveCursor(QTextCursor::End);
if (m_lastCwTextSource == CwTextSource::Tx) { m_cwText->insertPlainText(" "); }
m_lastCwTextSource = CwTextSource::Rx;
QTextCharFormat format;
const QString band = cost < 0.15f ? QStringLiteral("high")
: cost < 0.35f ? QStringLiteral("medium")
: cost < 0.60f ? QStringLiteral("fair") : QStringLiteral("low");
format.setForeground(AetherSDR::ThemeManager::instance().color(
m_cwText, QStringLiteral("color.cw.confidence.") + band));
QTextCursor cursor = m_cwText->textCursor();
cursor.insertText(clean, format);
m_cwText->moveCursor(QTextCursor::End);
}
#endif

Expand Down
4 changes: 4 additions & 0 deletions src/gui/PanadapterApplet.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class PanadapterApplet : public QWidget {

// CW decode panel
void setCwPanelVisible(bool visible);
// The four CW confidence colors, lowest cost (green) to highest (red).
static QString cwCostColor(float cost);
void appendCwText(const QString& text, float cost = 0.0f);
void appendCwTextTx(const QString& text, float cost = 0.0f);
void setCwStats(float pitchHz, float speedWpm);
Expand All @@ -76,6 +78,8 @@ class PanadapterApplet : public QWidget {
void setCwBackendState(const QString& key, bool tuning, const QString& status, bool preparing,
bool canRetry, const QString& detail);
void appendUnscoredCwText(const QString& text);
// Colored like appendCwText, but never dropped by the Sens threshold.
void appendColoredCwText(const QString& text, float cost);
#endif
QPushButton* lockPitchButton() const { return m_lockPitchBtn; }
QPushButton* lockSpeedButton() const { return m_lockSpeedBtn; }
Expand Down
Loading
Loading