Skip to content

Commit deeec3d

Browse files
committed
feat(subtitles): add subtitle processing stats
1 parent ec8daec commit deeec3d

7 files changed

Lines changed: 402 additions & 58 deletions

File tree

messages/en.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@
8787
"home_rail_duration": "Duration",
8888
"home_rail_segments": "Segments",
8989
"home_rail_words": "Words",
90+
"home_rail_gate": "Gate",
91+
"home_rail_gate_empty": "No evaluations",
92+
"home_rail_gate_seq": "#",
93+
"home_rail_gate_decision": "Decision",
94+
"home_rail_gate_words": "Words",
95+
"home_rail_gate_distance": "Dist",
96+
"home_rail_gate_time": "ms",
97+
"home_rail_gate_emit": "Emit",
98+
"home_rail_gate_suppress": "Suppress",
99+
"home_rail_gate_reason_empty": "Empty",
100+
"home_rail_gate_reason_duplicate": "Duplicate",
101+
"home_rail_gate_reason_drastic": "Drastic",
90102
"home_minute_label": "MINUTE {minute}",
91103
"home_toolbar_pause": "Pause",
92104
"home_toolbar_resume": "Resume",
@@ -97,4 +109,4 @@
97109
"audio_device_default": "Default",
98110
"locale_label_en": "English",
99111
"locale_label_ru": "Русский"
100-
}
112+
}

messages/ru.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@
8787
"home_rail_duration": "Длительность",
8888
"home_rail_segments": "Сегменты",
8989
"home_rail_words": "Слова",
90+
"home_rail_gate": "Фильтр",
91+
"home_rail_gate_empty": "Нет проверок",
92+
"home_rail_gate_seq": "#",
93+
"home_rail_gate_decision": "Решение",
94+
"home_rail_gate_words": "Слова",
95+
"home_rail_gate_distance": "Расст.",
96+
"home_rail_gate_time": "мс",
97+
"home_rail_gate_emit": "Выпуск",
98+
"home_rail_gate_suppress": "Подавл.",
99+
"home_rail_gate_reason_empty": "Пусто",
100+
"home_rail_gate_reason_duplicate": "Дубль",
101+
"home_rail_gate_reason_drastic": "Резко",
90102
"home_minute_label": "МИНУТА {minute}",
91103
"home_toolbar_pause": "Пауза",
92104
"home_toolbar_resume": "Продолжить",

rust/core/src/segments.rs

Lines changed: 209 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const DRASTIC_MIN_OLD_WORDS: usize = 4;
1212
const DRASTIC_SHRINK_RATIO: f32 = 0.60;
1313
const DRASTIC_EDIT_RATIO: f32 = 0.50;
1414
const DRASTIC_MIN_EDIT_WORDS: usize = 3;
15+
const GATE_TELEMETRY_MAX_ENTRIES: usize = 50;
1516

1617
#[derive(Debug, Clone)]
1718
pub struct SegmentAccumulator {
@@ -67,6 +68,7 @@ impl SegmentAccumulator {
6768
pub struct SegmentEmissionGate {
6869
last_emitted: Option<EmissionSnapshot>,
6970
pending: Option<PendingCandidate>,
71+
next_sequence: u64,
7072
}
7173

7274
#[derive(Debug, Clone)]
@@ -75,13 +77,68 @@ pub enum SegmentEmissionDecision {
7577
Suppress(SegmentSuppressionReason),
7678
}
7779

78-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80+
#[derive(
81+
Debug,
82+
Clone,
83+
Copy,
84+
PartialEq,
85+
Eq,
86+
serde::Serialize,
87+
serde::Deserialize,
88+
specta::Type,
89+
)]
7990
pub enum SegmentSuppressionReason {
8091
Empty,
8192
DuplicateNormalizedText,
8293
PendingDrasticChange,
8394
}
8495

96+
#[derive(Debug, Clone)]
97+
pub struct SegmentEmissionGateEvaluation {
98+
pub decision: SegmentEmissionDecision,
99+
pub telemetry: GateEvaluationTelemetryEntry,
100+
}
101+
102+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
103+
pub enum SegmentEmissionDecisionKind {
104+
Emit,
105+
Suppress,
106+
}
107+
108+
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
109+
#[serde(default)]
110+
pub struct GateTelemetryState {
111+
pub entries: Vec<GateEvaluationTelemetryEntry>,
112+
}
113+
114+
impl GateTelemetryState {
115+
pub fn push(&mut self, entry: GateEvaluationTelemetryEntry) {
116+
self.entries.push(entry);
117+
118+
let overflow = self.entries.len().saturating_sub(GATE_TELEMETRY_MAX_ENTRIES);
119+
if overflow > 0 {
120+
self.entries.drain(0..overflow);
121+
}
122+
}
123+
}
124+
125+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
126+
pub struct GateEvaluationTelemetryEntry {
127+
pub sequence: u64,
128+
pub segment_id: String,
129+
pub candidate_words: u64,
130+
pub last_emitted_words: u64,
131+
pub decision: SegmentEmissionDecisionKind,
132+
pub suppression_reason: Option<SegmentSuppressionReason>,
133+
pub is_drastic: Option<bool>,
134+
pub distance: Option<u64>,
135+
pub normalize_ms: f64,
136+
pub validation_ms: f64,
137+
pub drastic_check_ms: f64,
138+
pub distance_ms: f64,
139+
pub evaluate_ms: f64,
140+
}
141+
85142
#[derive(Debug, Clone)]
86143
struct EmissionSnapshot {
87144
normalized_text: String,
@@ -104,19 +161,26 @@ impl SegmentEmissionGate {
104161
Self {
105162
last_emitted: None,
106163
pending: None,
164+
next_sequence: 1,
107165
}
108166
}
109167

110-
pub fn evaluate(&mut self, candidate: WhisperSegment) -> SegmentEmissionDecision {
168+
pub fn evaluate(&mut self, candidate: WhisperSegment) -> SegmentEmissionGateEvaluation {
111169
let evaluate_started = Instant::now();
112170
let segment_id = candidate.id.clone();
171+
let sequence = self.next_sequence;
172+
self.next_sequence += 1;
113173

114174
let normalize_started = Instant::now();
115175
let normalized_text = normalized_segment_text(&candidate);
116176
let normalize_duration = normalize_started.elapsed();
117177

118-
let mut telemetry =
119-
GateEvaluationTelemetry::new(segment_id, normalize_duration, evaluate_started);
178+
let mut telemetry = GateEvaluationTelemetry::new(
179+
sequence,
180+
segment_id,
181+
normalize_duration,
182+
evaluate_started,
183+
);
120184
let validation_started = Instant::now();
121185
let candidate_words = words(&normalized_text);
122186
telemetry.candidate_words = candidate_words.len();
@@ -184,7 +248,7 @@ impl SegmentEmissionGate {
184248
candidate: WhisperSegment,
185249
normalized_text: String,
186250
telemetry: GateEvaluationTelemetry,
187-
) -> SegmentEmissionDecision {
251+
) -> SegmentEmissionGateEvaluation {
188252
self.last_emitted = Some(EmissionSnapshot { normalized_text });
189253
self.pending = None;
190254
telemetry.emit(candidate)
@@ -201,6 +265,7 @@ impl SegmentEmissionGate {
201265
}
202266

203267
struct GateEvaluationTelemetry {
268+
sequence: u64,
204269
segment_id: String,
205270
candidate_words: usize,
206271
last_emitted_words: usize,
@@ -221,8 +286,14 @@ struct DrasticChangeCheck {
221286
}
222287

223288
impl GateEvaluationTelemetry {
224-
fn new(segment_id: String, normalize_duration: Duration, evaluate_started: Instant) -> Self {
289+
fn new(
290+
sequence: u64,
291+
segment_id: String,
292+
normalize_duration: Duration,
293+
evaluate_started: Instant,
294+
) -> Self {
225295
Self {
296+
sequence,
226297
segment_id,
227298
candidate_words: 0,
228299
last_emitted_words: 0,
@@ -236,35 +307,68 @@ impl GateEvaluationTelemetry {
236307
}
237308
}
238309

239-
fn emit(self, segment: WhisperSegment) -> SegmentEmissionDecision {
240-
self.log("emit", None);
241-
SegmentEmissionDecision::Emit(segment)
310+
fn emit(self, segment: WhisperSegment) -> SegmentEmissionGateEvaluation {
311+
let telemetry = self.into_entry(SegmentEmissionDecisionKind::Emit, None);
312+
log_gate_telemetry(&telemetry);
313+
314+
SegmentEmissionGateEvaluation {
315+
decision: SegmentEmissionDecision::Emit(segment),
316+
telemetry,
317+
}
242318
}
243319

244-
fn suppress(self, reason: SegmentSuppressionReason) -> SegmentEmissionDecision {
245-
self.log("suppress", Some(reason));
246-
SegmentEmissionDecision::Suppress(reason)
320+
fn suppress(self, reason: SegmentSuppressionReason) -> SegmentEmissionGateEvaluation {
321+
let telemetry =
322+
self.into_entry(SegmentEmissionDecisionKind::Suppress, Some(reason));
323+
log_gate_telemetry(&telemetry);
324+
325+
SegmentEmissionGateEvaluation {
326+
decision: SegmentEmissionDecision::Suppress(reason),
327+
telemetry,
328+
}
247329
}
248330

249-
fn log(&self, decision: &str, suppression_reason: Option<SegmentSuppressionReason>) {
250-
debug!(
251-
segment_id = %self.segment_id,
252-
candidate_words = self.candidate_words,
253-
last_emitted_words = self.last_emitted_words,
254-
decision = decision,
255-
suppression_reason = ?suppression_reason,
256-
is_drastic = ?self.is_drastic,
257-
distance = ?self.distance,
258-
normalize_ms = elapsed_ms(self.normalize_duration),
259-
validation_ms = elapsed_ms(self.validation_duration),
260-
drastic_check_ms = elapsed_ms(self.drastic_check_duration),
261-
distance_ms = elapsed_ms(self.distance_duration),
262-
evaluate_ms = elapsed_ms(self.evaluate_started.elapsed()),
263-
"segment emission gate evaluated"
264-
);
331+
fn into_entry(
332+
self,
333+
decision: SegmentEmissionDecisionKind,
334+
suppression_reason: Option<SegmentSuppressionReason>,
335+
) -> GateEvaluationTelemetryEntry {
336+
GateEvaluationTelemetryEntry {
337+
sequence: self.sequence,
338+
segment_id: self.segment_id,
339+
candidate_words: self.candidate_words as u64,
340+
last_emitted_words: self.last_emitted_words as u64,
341+
decision,
342+
suppression_reason,
343+
is_drastic: self.is_drastic,
344+
distance: self.distance.map(|distance| distance as u64),
345+
normalize_ms: elapsed_ms(self.normalize_duration),
346+
validation_ms: elapsed_ms(self.validation_duration),
347+
drastic_check_ms: elapsed_ms(self.drastic_check_duration),
348+
distance_ms: elapsed_ms(self.distance_duration),
349+
evaluate_ms: elapsed_ms(self.evaluate_started.elapsed()),
350+
}
265351
}
266352
}
267353

354+
fn log_gate_telemetry(entry: &GateEvaluationTelemetryEntry) {
355+
debug!(
356+
segment_id = %entry.segment_id,
357+
candidate_words = entry.candidate_words,
358+
last_emitted_words = entry.last_emitted_words,
359+
decision = ?entry.decision,
360+
suppression_reason = ?entry.suppression_reason,
361+
is_drastic = ?entry.is_drastic,
362+
distance = ?entry.distance,
363+
normalize_ms = entry.normalize_ms,
364+
validation_ms = entry.validation_ms,
365+
drastic_check_ms = entry.drastic_check_ms,
366+
distance_ms = entry.distance_ms,
367+
evaluate_ms = entry.evaluate_ms,
368+
"segment emission gate evaluated"
369+
);
370+
}
371+
268372
fn normalized_segment_text(segment: &WhisperSegment) -> String {
269373
let text = segment
270374
.items
@@ -364,8 +468,11 @@ mod tests {
364468
}
365469
}
366470

367-
fn assert_emits(decision: SegmentEmissionDecision, expected_text: &str) {
368-
match decision {
471+
fn assert_emits(evaluation: SegmentEmissionGateEvaluation, expected_text: &str) {
472+
assert_eq!(evaluation.telemetry.decision, SegmentEmissionDecisionKind::Emit);
473+
assert!(evaluation.telemetry.suppression_reason.is_none());
474+
475+
match evaluation.decision {
369476
SegmentEmissionDecision::Emit(segment) => {
370477
assert_eq!(segment.items[0].text, expected_text);
371478
}
@@ -376,10 +483,16 @@ mod tests {
376483
}
377484

378485
fn assert_suppresses(
379-
decision: SegmentEmissionDecision,
486+
evaluation: SegmentEmissionGateEvaluation,
380487
expected_reason: SegmentSuppressionReason,
381488
) {
382-
match decision {
489+
assert_eq!(
490+
evaluation.telemetry.decision,
491+
SegmentEmissionDecisionKind::Suppress
492+
);
493+
assert_eq!(evaluation.telemetry.suppression_reason, Some(expected_reason));
494+
495+
match evaluation.decision {
383496
SegmentEmissionDecision::Emit(segment) => {
384497
panic!("expected suppress, emitted {:?}", segment.items);
385498
}
@@ -435,7 +548,14 @@ mod tests {
435548
fn emission_gate_emits_first_meaningful_update() {
436549
let mut gate = SegmentEmissionGate::new();
437550

438-
assert_emits(gate.evaluate(segment("segment-0", "hello")), "hello");
551+
let evaluation = gate.evaluate(segment("segment-0", "hello"));
552+
553+
assert_eq!(evaluation.telemetry.sequence, 1);
554+
assert_eq!(evaluation.telemetry.segment_id, "segment-0");
555+
assert_eq!(evaluation.telemetry.candidate_words, 1);
556+
assert_eq!(evaluation.telemetry.last_emitted_words, 0);
557+
assert!(evaluation.telemetry.evaluate_ms >= evaluation.telemetry.normalize_ms);
558+
assert_emits(evaluation, "hello");
439559
}
440560

441561
#[test]
@@ -538,4 +658,60 @@ mod tests {
538658

539659
assert_emits(gate.evaluate(segment("segment-1", "alpha")), "alpha");
540660
}
661+
662+
#[test]
663+
fn emission_gate_telemetry_records_suppression_details() {
664+
let mut gate = SegmentEmissionGate::new();
665+
666+
assert_emits(
667+
gate.evaluate(segment("segment-0", "alpha beta gamma delta")),
668+
"alpha beta gamma delta",
669+
);
670+
let evaluation = gate.evaluate(segment("segment-0", "one two three four"));
671+
672+
assert_eq!(evaluation.telemetry.sequence, 2);
673+
assert_eq!(
674+
evaluation.telemetry.decision,
675+
SegmentEmissionDecisionKind::Suppress
676+
);
677+
assert_eq!(
678+
evaluation.telemetry.suppression_reason,
679+
Some(SegmentSuppressionReason::PendingDrasticChange)
680+
);
681+
assert_eq!(evaluation.telemetry.candidate_words, 4);
682+
assert_eq!(evaluation.telemetry.last_emitted_words, 4);
683+
assert_eq!(evaluation.telemetry.is_drastic, Some(true));
684+
assert_eq!(evaluation.telemetry.distance, Some(4));
685+
assert_suppresses(
686+
evaluation,
687+
SegmentSuppressionReason::PendingDrasticChange,
688+
);
689+
}
690+
691+
#[test]
692+
fn gate_telemetry_state_keeps_latest_entries() {
693+
let mut state = GateTelemetryState::default();
694+
695+
for sequence in 1..=55 {
696+
state.push(GateEvaluationTelemetryEntry {
697+
sequence,
698+
segment_id: "segment-0".to_owned(),
699+
candidate_words: 1,
700+
last_emitted_words: 0,
701+
decision: SegmentEmissionDecisionKind::Emit,
702+
suppression_reason: None,
703+
is_drastic: None,
704+
distance: None,
705+
normalize_ms: 0.0,
706+
validation_ms: 0.0,
707+
drastic_check_ms: 0.0,
708+
distance_ms: 0.0,
709+
evaluate_ms: 0.0,
710+
});
711+
}
712+
713+
assert_eq!(state.entries.len(), GATE_TELEMETRY_MAX_ENTRIES);
714+
assert_eq!(state.entries[0].sequence, 6);
715+
assert_eq!(state.entries.last().unwrap().sequence, 55);
716+
}
541717
}

0 commit comments

Comments
 (0)