Added a module to coalesce adjacent same-speaker segments

This commit is contained in:
2026-04-27 19:30:00 -05:00
parent 13d972cb24
commit aab6d12730
12 changed files with 919 additions and 28 deletions

View File

@@ -0,0 +1,118 @@
package coalesce
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/seriatim/internal/model"
)
// Summary records deterministic counters for a coalesce pass.
type Summary struct {
OriginalSegmentsMerged int
CoalescedSegments int
}
// Apply merges adjacent same-speaker segments in the transcript's current order.
func Apply(in model.MergedTranscript, gap float64) (model.MergedTranscript, Summary) {
if len(in.Segments) < 2 {
return in, Summary{}
}
out := model.MergedTranscript{
Segments: make([]model.Segment, 0, len(in.Segments)),
OverlapGroups: in.OverlapGroups,
}
summary := Summary{}
coalescedID := 0
current := newRun(in.Segments[0])
for _, segment := range in.Segments[1:] {
if current.canMerge(segment, gap) {
current.add(segment)
continue
}
coalescedID = appendRun(&out, current, coalescedID, &summary)
current = newRun(segment)
}
appendRun(&out, current, coalescedID, &summary)
return out, summary
}
type run struct {
segments []model.Segment
}
func newRun(segment model.Segment) run {
return run{
segments: []model.Segment{segment},
}
}
func (r run) canMerge(next model.Segment, gap float64) bool {
current := r.segments[len(r.segments)-1]
return current.Speaker == next.Speaker && next.Start-current.End <= gap
}
func (r *run) add(segment model.Segment) {
r.segments = append(r.segments, segment)
}
func appendRun(out *model.MergedTranscript, current run, coalescedID int, summary *Summary) int {
if len(current.segments) == 1 {
out.Segments = append(out.Segments, current.segments[0])
return coalescedID
}
coalescedID++
out.Segments = append(out.Segments, current.coalescedSegment(coalescedID))
summary.OriginalSegmentsMerged += len(current.segments)
summary.CoalescedSegments++
return coalescedID
}
func (r run) coalescedSegment(id int) model.Segment {
first := r.segments[0]
merged := model.Segment{
Source: first.Source,
SourceRef: fmt.Sprintf("coalesce:%d", id),
DerivedFrom: make([]string, 0, len(r.segments)),
Speaker: first.Speaker,
Start: first.Start,
End: first.End,
Words: make([]model.Word, 0),
}
text := make([]string, 0, len(r.segments))
for _, segment := range r.segments {
if segment.Start < merged.Start {
merged.Start = segment.Start
}
if segment.End > merged.End {
merged.End = segment.End
}
if segment.Source != merged.Source {
merged.Source = "derived"
}
if trimmed := strings.TrimSpace(segment.Text); trimmed != "" {
text = append(text, trimmed)
}
merged.Words = append(merged.Words, segment.Words...)
merged.DerivedFrom = append(merged.DerivedFrom, segmentRef(segment))
}
merged.Text = strings.Join(text, " ")
return merged
}
func segmentRef(segment model.Segment) string {
if segment.SourceSegmentIndex != nil {
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
}
if segment.SourceRef != "" {
return segment.SourceRef
}
return segment.Source
}

View File

@@ -0,0 +1,156 @@
package coalesce
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/seriatim/internal/model"
)
func TestApplyMergesConsecutiveSameSpeakerWithinGap(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segment("a.json", 0, "Alice", 1, 2, " first "),
segment("a.json", 1, "Alice", 4, 5, "second"),
},
}
got, summary := Apply(merged, 3)
if summary.OriginalSegmentsMerged != 2 || summary.CoalescedSegments != 1 {
t.Fatalf("summary = %#v", summary)
}
if len(got.Segments) != 1 {
t.Fatalf("segment count = %d, want 1", len(got.Segments))
}
segment := got.Segments[0]
if segment.Text != "first second" {
t.Fatalf("text = %q", segment.Text)
}
if segment.Start != 1 || segment.End != 5 {
t.Fatalf("bounds = %f-%f, want 1-5", segment.Start, segment.End)
}
if segment.Source != "a.json" {
t.Fatalf("source = %q, want a.json", segment.Source)
}
if segment.SourceRef != "coalesce:1" {
t.Fatalf("source_ref = %q, want coalesce:1", segment.SourceRef)
}
if segment.SourceSegmentIndex != nil {
t.Fatalf("source_segment_index = %d, want nil", *segment.SourceSegmentIndex)
}
if !reflect.DeepEqual(segment.DerivedFrom, []string{"a.json#0", "a.json#1"}) {
t.Fatalf("derived_from = %v", segment.DerivedFrom)
}
}
func TestApplyDoesNotMergeSameSpeakerBeyondGap(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segment("a.json", 0, "Alice", 1, 2, "first"),
segment("a.json", 1, "Alice", 5.1, 6, "second"),
},
}
got, summary := Apply(merged, 3)
if summary.OriginalSegmentsMerged != 0 || summary.CoalescedSegments != 0 {
t.Fatalf("summary = %#v", summary)
}
if !reflect.DeepEqual(got.Segments, merged.Segments) {
t.Fatalf("segments changed:\ngot %#v\nwant %#v", got.Segments, merged.Segments)
}
}
func TestApplyDoesNotMergeAcrossDifferentSpeaker(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segment("a.json", 0, "Alice", 1, 2, "first"),
segment("b.json", 0, "Bob", 2.5, 3, "bob"),
segment("a.json", 1, "Alice", 3.5, 4, "second"),
},
}
got, summary := Apply(merged, 3)
if summary.OriginalSegmentsMerged != 0 || summary.CoalescedSegments != 0 {
t.Fatalf("summary = %#v", summary)
}
if len(got.Segments) != 3 {
t.Fatalf("segment count = %d, want 3", len(got.Segments))
}
}
func TestApplyMergesNegativeGapOverlap(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segment("a.json", 0, "Alice", 1, 4, "first"),
segment("a.json", 1, "Alice", 3, 5, "second"),
},
}
got, summary := Apply(merged, 0)
if summary.OriginalSegmentsMerged != 2 || summary.CoalescedSegments != 1 {
t.Fatalf("summary = %#v", summary)
}
if got.Segments[0].Start != 1 || got.Segments[0].End != 5 {
t.Fatalf("bounds = %f-%f, want 1-5", got.Segments[0].Start, got.Segments[0].End)
}
}
func TestApplyHonorsCurrentOrder(t *testing.T) {
merged := model.MergedTranscript{
Segments: []model.Segment{
segment("a.json", 0, "Alice", 10, 11, "later"),
segment("a.json", 1, "Alice", 1, 2, "earlier"),
},
}
got, summary := Apply(merged, 3)
if summary.OriginalSegmentsMerged != 2 || summary.CoalescedSegments != 1 {
t.Fatalf("summary = %#v", summary)
}
if got.Segments[0].Text != "later earlier" {
t.Fatalf("text = %q, want current-order merge", got.Segments[0].Text)
}
if got.Segments[0].Start != 1 || got.Segments[0].End != 11 {
t.Fatalf("bounds = %f-%f, want 1-11", got.Segments[0].Start, got.Segments[0].End)
}
}
func TestApplyDerivedProvenanceForMixedSourcesAndDerivedInputs(t *testing.T) {
first := segment("a.json", 0, "Alice", 1, 2, "first")
second := model.Segment{
Source: "b.json",
SourceRef: "word-run:1:1:1",
DerivedFrom: []string{"b.json#0"},
Speaker: "Alice",
Start: 2.5,
End: 3,
Text: "second",
}
got, _ := Apply(model.MergedTranscript{Segments: []model.Segment{first, second}}, 3)
segment := got.Segments[0]
if segment.Source != "derived" {
t.Fatalf("source = %q, want derived", segment.Source)
}
if !reflect.DeepEqual(segment.DerivedFrom, []string{"a.json#0", "word-run:1:1:1"}) {
t.Fatalf("derived_from = %v", segment.DerivedFrom)
}
}
func segment(source string, sourceIndex int, speaker string, start float64, end float64, text string) model.Segment {
return model.Segment{
Source: source,
SourceSegmentIndex: intPtr(sourceIndex),
Speaker: speaker,
Start: start,
End: end,
Text: text,
Words: []model.Word{
{Text: text, Start: start, End: end, Timed: true},
},
}
}
func intPtr(value int) *int {
return &value
}