forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaudio_timeline_mapper.dart
More file actions
127 lines (114 loc) · 4.46 KB
/
Copy pathaudio_timeline_mapper.dart
File metadata and controls
127 lines (114 loc) · 4.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
/// Maps between the conversation's wall-clock timeline and the dense playback
/// artifact (one MP3 per conversation, inter-part gaps collapsed).
///
/// Spans come from the backend's `conversation_audio` stamp; `wallOffset` is
/// seconds relative to conversation.startedAt — the same basis as
/// TranscriptSegment.start — and `artifactOffset` is seconds into the MP3.
class ConversationAudioSpan {
final String fileId;
final double wallOffset;
final double artifactOffset;
final double len;
const ConversationAudioSpan({
required this.fileId,
required this.wallOffset,
required this.artifactOffset,
required this.len,
});
factory ConversationAudioSpan.fromJson(Map<String, dynamic> json) {
return ConversationAudioSpan(
fileId: json['file_id'] ?? '',
wallOffset: (json['wall_offset'] ?? 0).toDouble(),
artifactOffset: (json['artifact_offset'] ?? 0).toDouble(),
len: (json['len'] ?? 0).toDouble(),
);
}
double get wallEnd => wallOffset + len;
double get artifactEnd => artifactOffset + len;
}
class AudioTimelineMapper {
final List<ConversationAudioSpan> spans;
AudioTimelineMapper(List<ConversationAudioSpan> spans)
: spans = List.of(spans)..sort((a, b) => a.wallOffset.compareTo(b.wallOffset));
bool get isEmpty => spans.isEmpty;
double get capturedDuration => spans.isEmpty ? 0 : spans.last.artifactEnd;
double get wallDuration => spans.isEmpty ? 0 : spans.last.wallEnd;
/// Wall-clock seconds -> MP3 seconds. A position inside a collapsed gap
/// snaps forward to the next span's start; before/after the timeline clamps.
///
/// Use this for scrubber interaction. Segment taps must use
/// [wallToArtifactStrict] so a tap in a collapsed gap does not jump into a
/// later span (#4471).
double wallToArtifact(double wallSeconds) {
if (spans.isEmpty) return 0;
if (wallSeconds <= spans.first.wallOffset) return spans.first.artifactOffset;
var lo = 0, hi = spans.length - 1;
while (lo < hi) {
final mid = (lo + hi + 1) >> 1;
if (spans[mid].wallOffset <= wallSeconds) {
lo = mid;
} else {
hi = mid - 1;
}
}
final span = spans[lo];
if (wallSeconds <= span.wallEnd) {
return span.artifactOffset + (wallSeconds - span.wallOffset);
}
// In the gap after this span: snap to the next span, or clamp at the end.
return lo + 1 < spans.length ? spans[lo + 1].artifactOffset : capturedDuration;
}
/// Wall-clock seconds -> MP3 seconds only when [wallSeconds] falls inside a
/// span. Returns null in collapsed gaps / outside the timeline (macOS
/// CapturePlayback contract). Half-open: [wallOffset, wallEnd).
double? wallToArtifactStrict(double wallSeconds) {
if (spans.isEmpty) return null;
for (final span in spans) {
if (wallSeconds >= span.wallOffset && wallSeconds < span.wallEnd) {
return span.artifactOffset + (wallSeconds - span.wallOffset);
}
}
return null;
}
/// Like [wallToArtifactStrict] but inclusive of each span's [ConversationAudioSpan.wallEnd]
/// so a segment's stop time on a span boundary still maps.
double? wallToArtifactStrictInclusive(double wallSeconds) {
if (spans.isEmpty) return null;
for (final span in spans) {
if (wallSeconds >= span.wallOffset && wallSeconds <= span.wallEnd) {
return span.artifactOffset + (wallSeconds - span.wallOffset);
}
}
return null;
}
/// MP3 seconds -> wall-clock seconds.
double artifactToWall(double artifactSeconds) {
if (spans.isEmpty) return 0;
if (artifactSeconds <= spans.first.artifactOffset) return spans.first.wallOffset;
var lo = 0, hi = spans.length - 1;
while (lo < hi) {
final mid = (lo + hi + 1) >> 1;
if (spans[mid].artifactOffset <= artifactSeconds) {
lo = mid;
} else {
hi = mid - 1;
}
}
final span = spans[lo];
if (artifactSeconds <= span.artifactEnd) {
return span.wallOffset + (artifactSeconds - span.artifactOffset);
}
return lo + 1 < spans.length ? spans[lo + 1].wallOffset : wallDuration;
}
/// Collapsed-gap ranges on the wall timeline, as (start, end) pairs — used
/// to shade the scrubber where no audio was captured.
List<(double, double)> get gapRangesWall {
final gaps = <(double, double)>[];
for (var i = 0; i + 1 < spans.length; i++) {
final end = spans[i].wallEnd;
final next = spans[i + 1].wallOffset;
if (next > end) gaps.add((end, next));
}
return gaps;
}
}