Skip to content

Commit b0ad8da

Browse files
committed
fix(diarization): correct sherpa-onnx symbol name + lint cleanup
CI failures on #9654: * sherpa-onnx-grpc-{tts,transcription} and sherpa-onnx-realtime panicked at backend startup with `undefined symbol: SherpaOnnxDestroyOfflineSpeakerDiarizationResult`. Upstream's actual symbol is SherpaOnnxOfflineSpeakerDiarizationDestroyResult (Destroy in the middle, not the prefix); the rest of the diarization surface follows the same naming pattern. The mismatched name made purego.RegisterLibFunc fail at dlopen time and crashed the gRPC server before the BeforeAll could probe Health, taking down every sherpa-onnx test job — not just the diarization-related ones. * golangci-lint flagged 5 errcheck violations on new defer cleanups (os.RemoveAll / Close / conn.Close); wrap each in a `defer func() { _ = X() }()` closure (matches the pattern other LocalAI files use for new code, since pre-existing bare defers are grandfathered in via new-from-merge-base). * golangci-lint also flagged forbidigo violations: the new diarization_test.go files used testing.T-style `t.Errorf` / `t.Fatalf`, which are forbidden by the project's coding-style policy (.agents/coding-style.md). Convert both files to Ginkgo/Gomega Describe/It with Expect(...) — they get picked up by the existing TestBackend / TestOpenAI suites, no new suite plumbing needed. * modernize linter: tightened the diarization segment loop to `for i := range int(numSegments)` (Go 1.22+ idiom). Verified locally: golangci-lint with new-from-merge-base=origin/master reports 0 issues across all touched packages, and the four mocked diarization e2e specs in tests/e2e/mock_backend_test.go still pass. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code]
1 parent 60f8c44 commit b0ad8da

6 files changed

Lines changed: 115 additions & 153 deletions

File tree

backend/go/sherpa-onnx/backend.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ func loadSherpaLibsOnce() error {
397397
{&sherpaOfflineSpeakerDiarizationResultGetNumSpeakers, "SherpaOnnxOfflineSpeakerDiarizationResultGetNumSpeakers"},
398398
{&sherpaOfflineSpeakerDiarizationResultSortByStartTime, "SherpaOnnxOfflineSpeakerDiarizationResultSortByStartTime"},
399399
{&sherpaOfflineSpeakerDiarizationDestroySegment, "SherpaOnnxOfflineSpeakerDiarizationDestroySegment"},
400-
{&sherpaDestroyOfflineSpeakerDiarizationResult, "SherpaOnnxDestroyOfflineSpeakerDiarizationResult"},
400+
{&sherpaDestroyOfflineSpeakerDiarizationResult, "SherpaOnnxOfflineSpeakerDiarizationDestroyResult"},
401401
} {
402402
purego.RegisterLibFunc(r.ptr, capi, r.name)
403403
}
@@ -1438,7 +1438,7 @@ func (s *SherpaBackend) Diarize(req *pb.DiarizeRequest) (pb.DiarizeResponse, err
14381438
if err != nil {
14391439
return pb.DiarizeResponse{}, fmt.Errorf("failed to create temp dir: %w", err)
14401440
}
1441-
defer os.RemoveAll(dir)
1441+
defer func() { _ = os.RemoveAll(dir) }()
14421442

14431443
wavPath := filepath.Join(dir, "input.wav")
14441444
if err := utils.AudioToWav(req.Dst, wavPath); err != nil {
@@ -1487,13 +1487,13 @@ func (s *SherpaBackend) Diarize(req *pb.DiarizeRequest) (pb.DiarizeResponse, err
14871487
defer sherpaOfflineSpeakerDiarizationDestroySegment(segs)
14881488

14891489
out := make([]*pb.DiarizeSegment, 0, numSegments)
1490-
for i := int32(0); i < numSegments; i++ {
1490+
for i := range int(numSegments) {
14911491
var start, end float32
14921492
var spk int32
1493-
shimDiarizeSegmentAt(segs, i,
1493+
shimDiarizeSegmentAt(segs, int32(i),
14941494
unsafe.Pointer(&start), unsafe.Pointer(&end), unsafe.Pointer(&spk))
14951495
out = append(out, &pb.DiarizeSegment{
1496-
Id: i,
1496+
Id: int32(i),
14971497
Start: start,
14981498
End: end,
14991499
Speaker: strconv.FormatInt(int64(spk), 10),

core/backend/diarization_test.go

Lines changed: 63 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,76 @@
11
package backend
22

33
import (
4-
"testing"
5-
64
"github.com/mudler/LocalAI/pkg/grpc/proto"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
78
)
89

9-
func TestDiarizationResultFromProto_NormalisesSpeakerLabels(t *testing.T) {
10-
in := &proto.DiarizeResponse{
11-
Duration: 10.5,
12-
Language: "en",
13-
Segments: []*proto.DiarizeSegment{
14-
{Start: 0.0, End: 1.0, Speaker: "5", Text: "hi"},
15-
{Start: 1.0, End: 2.0, Speaker: "2"},
16-
{Start: 2.0, End: 3.5, Speaker: "5"},
17-
{Start: 3.5, End: 4.0, Speaker: ""}, // empty → coerced to "0"
18-
},
19-
}
10+
var _ = Describe("diarizationResultFromProto", func() {
11+
It("normalises raw backend speaker labels to SPEAKER_NN in first-seen order", func() {
12+
in := &proto.DiarizeResponse{
13+
Duration: 10.5,
14+
Language: "en",
15+
Segments: []*proto.DiarizeSegment{
16+
{Start: 0.0, End: 1.0, Speaker: "5", Text: "hi"},
17+
{Start: 1.0, End: 2.0, Speaker: "2"},
18+
{Start: 2.0, End: 3.5, Speaker: "5"},
19+
{Start: 3.5, End: 4.0, Speaker: ""}, // empty → coerced to "0"
20+
},
21+
}
2022

21-
got := diarizationResultFromProto(in)
23+
got := diarizationResultFromProto(in)
2224

23-
if got.Task != "diarize" {
24-
t.Errorf("task = %q, want diarize", got.Task)
25-
}
26-
if got.NumSpeakers != 3 {
27-
t.Errorf("num_speakers = %d, want 3 (5, 2, 0)", got.NumSpeakers)
28-
}
29-
if got.Duration != 10.5 || got.Language != "en" {
30-
t.Errorf("metadata not preserved: duration=%v language=%q", got.Duration, got.Language)
31-
}
32-
if len(got.Segments) != 4 {
33-
t.Fatalf("segments=%d, want 4", len(got.Segments))
34-
}
35-
// First-seen-order normalization: "5"→SPEAKER_00, "2"→SPEAKER_01, ""→SPEAKER_02
36-
want := []struct {
37-
speaker string
38-
label string
39-
}{
40-
{"SPEAKER_00", "5"},
41-
{"SPEAKER_01", "2"},
42-
{"SPEAKER_00", "5"},
43-
{"SPEAKER_02", "0"},
44-
}
45-
for i, w := range want {
46-
if got.Segments[i].Speaker != w.speaker {
47-
t.Errorf("seg[%d].speaker = %q, want %q", i, got.Segments[i].Speaker, w.speaker)
25+
Expect(got.Task).To(Equal("diarize"))
26+
Expect(got.NumSpeakers).To(Equal(3), "expected 3 distinct speakers (5, 2, 0)")
27+
Expect(got.Duration).To(BeEquivalentTo(10.5))
28+
Expect(got.Language).To(Equal("en"))
29+
Expect(got.Segments).To(HaveLen(4))
30+
31+
// First-seen-order normalisation: "5"→SPEAKER_00, "2"→SPEAKER_01, ""→SPEAKER_02
32+
want := []struct {
33+
speaker string
34+
label string
35+
}{
36+
{"SPEAKER_00", "5"},
37+
{"SPEAKER_01", "2"},
38+
{"SPEAKER_00", "5"},
39+
{"SPEAKER_02", "0"},
4840
}
49-
if got.Segments[i].Label != w.label {
50-
t.Errorf("seg[%d].label = %q, want %q", i, got.Segments[i].Label, w.label)
41+
for i, w := range want {
42+
Expect(got.Segments[i].Speaker).To(Equal(w.speaker), "seg[%d].speaker", i)
43+
Expect(got.Segments[i].Label).To(Equal(w.label), "seg[%d].label", i)
5144
}
52-
}
5345

54-
// Per-speaker totals reflect cumulative speech duration and segment count.
55-
if len(got.Speakers) != 3 {
56-
t.Fatalf("speakers=%d, want 3", len(got.Speakers))
57-
}
58-
byID := map[string]float64{}
59-
countByID := map[string]int{}
60-
for _, sp := range got.Speakers {
61-
byID[sp.Id] = sp.TotalSpeechDuration
62-
countByID[sp.Id] = sp.SegmentCount
63-
}
64-
if byID["SPEAKER_00"] != 2.5 { // 1.0 + 1.5
65-
t.Errorf("SPEAKER_00 total = %v, want 2.5", byID["SPEAKER_00"])
66-
}
67-
if byID["SPEAKER_01"] != 1.0 {
68-
t.Errorf("SPEAKER_01 total = %v, want 1.0", byID["SPEAKER_01"])
69-
}
70-
if countByID["SPEAKER_00"] != 2 || countByID["SPEAKER_01"] != 1 || countByID["SPEAKER_02"] != 1 {
71-
t.Errorf("segment counts wrong: %v", countByID)
72-
}
73-
}
46+
// Per-speaker totals reflect cumulative speech duration and segment count.
47+
Expect(got.Speakers).To(HaveLen(3))
48+
byID := map[string]float64{}
49+
countByID := map[string]int{}
50+
for _, sp := range got.Speakers {
51+
byID[sp.Id] = sp.TotalSpeechDuration
52+
countByID[sp.Id] = sp.SegmentCount
53+
}
54+
Expect(byID["SPEAKER_00"]).To(BeNumerically("~", 2.5, 0.001), "1.0 + 1.5")
55+
Expect(byID["SPEAKER_01"]).To(BeNumerically("~", 1.0, 0.001))
56+
Expect(countByID["SPEAKER_00"]).To(Equal(2))
57+
Expect(countByID["SPEAKER_01"]).To(Equal(1))
58+
Expect(countByID["SPEAKER_02"]).To(Equal(1))
59+
})
7460

75-
func TestDiarizationResultFromProto_NilSafe(t *testing.T) {
76-
got := diarizationResultFromProto(nil)
77-
if got == nil || got.Segments == nil {
78-
t.Fatalf("nil input must return a non-nil result with a non-nil segments slice")
79-
}
80-
if len(got.Segments) != 0 {
81-
t.Errorf("nil input segments = %d, want 0", len(got.Segments))
82-
}
83-
}
61+
It("returns a non-nil result with a non-nil segments slice for nil input", func() {
62+
got := diarizationResultFromProto(nil)
63+
Expect(got).ToNot(BeNil())
64+
Expect(got.Segments).ToNot(BeNil())
65+
Expect(got.Segments).To(BeEmpty())
66+
})
8467

85-
func TestDiarizationResultFromProto_EmptySegmentsKeepsBackendSpeakerCount(t *testing.T) {
86-
// When the backend reports a non-zero NumSpeakers but no segments
87-
// (early stop, silence-only audio after VAD trim, etc.), the result
88-
// should still surface the backend's count rather than zero.
89-
in := &proto.DiarizeResponse{NumSpeakers: 2, Duration: 5}
90-
got := diarizationResultFromProto(in)
91-
if got.NumSpeakers != 2 {
92-
t.Errorf("num_speakers = %d, want 2", got.NumSpeakers)
93-
}
94-
if len(got.Segments) != 0 {
95-
t.Errorf("expected no segments, got %d", len(got.Segments))
96-
}
97-
}
68+
It("keeps the backend speaker count when no segments are returned", func() {
69+
// Backend reports a non-zero NumSpeakers but no segments (early stop,
70+
// silence-only audio after VAD trim). Surface the backend's count.
71+
in := &proto.DiarizeResponse{NumSpeakers: 2, Duration: 5}
72+
got := diarizationResultFromProto(in)
73+
Expect(got.NumSpeakers).To(Equal(2))
74+
Expect(got.Segments).To(BeEmpty())
75+
})
76+
})

core/http/endpoints/openai/diarization.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,13 @@ func DiarizationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, ap
8484
if err != nil {
8585
return err
8686
}
87-
defer f.Close()
87+
defer func() { _ = f.Close() }()
8888

8989
dir, err := os.MkdirTemp("", "diarize")
9090
if err != nil {
9191
return err
9292
}
93-
defer os.RemoveAll(dir)
93+
defer func() { _ = os.RemoveAll(dir) }()
9494

9595
dst := filepath.Join(dir, path.Base(file.Filename))
9696
dstFile, err := os.Create(dst)

core/http/endpoints/openai/diarization_test.go

Lines changed: 43 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,67 +2,50 @@ package openai
22

33
import (
44
"strings"
5-
"testing"
65

76
"github.com/mudler/LocalAI/core/schema"
8-
)
9-
10-
func TestRenderRTTM_FormatsSegmentsAsNISTRows(t *testing.T) {
11-
r := &schema.DiarizationResult{
12-
Segments: []schema.DiarizationSegment{
13-
{Id: 0, Speaker: "SPEAKER_00", Start: 0, End: 2.34},
14-
{Id: 1, Speaker: "SPEAKER_01", Start: 2.34, End: 4.10},
15-
},
16-
}
17-
out := renderRTTM(r, "/tmp/uploads/meeting.wav")
18-
19-
lines := strings.Split(strings.TrimSpace(out), "\n")
20-
if len(lines) != 2 {
21-
t.Fatalf("expected 2 RTTM rows, got %d:\n%s", len(lines), out)
22-
}
23-
24-
// File ID should be the basename without extension; durations are
25-
// (end - start) with millisecond precision.
26-
wantPrefix := "SPEAKER meeting 1 "
27-
if !strings.HasPrefix(lines[0], wantPrefix) {
28-
t.Errorf("row 0 prefix = %q, want %q…", lines[0][:min(len(lines[0]), len(wantPrefix))], wantPrefix)
29-
}
307

31-
if !strings.Contains(lines[0], " 0.000 2.340 <NA> <NA> SPEAKER_00 <NA> <NA>") {
32-
t.Errorf("row 0 = %q, missing expected payload", lines[0])
33-
}
34-
if !strings.Contains(lines[1], " 2.340 1.760 <NA> <NA> SPEAKER_01 <NA> <NA>") {
35-
t.Errorf("row 1 = %q, missing expected payload", lines[1])
36-
}
37-
}
38-
39-
func TestRenderRTTM_NegativeDurationClampedToZero(t *testing.T) {
40-
// Backends shouldn't emit end<start, but if they do (clock skew during a
41-
// long pipeline), the RTTM duration must stay non-negative.
42-
r := &schema.DiarizationResult{
43-
Segments: []schema.DiarizationSegment{
44-
{Id: 0, Speaker: "SPEAKER_00", Start: 5, End: 4},
45-
},
46-
}
47-
out := renderRTTM(r, "x.wav")
48-
if !strings.Contains(out, " 5.000 0.000 ") {
49-
t.Errorf("expected duration clamped to 0, got: %q", out)
50-
}
51-
}
52-
53-
func TestRenderRTTM_FallbackFileID(t *testing.T) {
54-
r := &schema.DiarizationResult{
55-
Segments: []schema.DiarizationSegment{{Id: 0, Speaker: "SPEAKER_00", Start: 0, End: 1}},
56-
}
57-
out := renderRTTM(r, "")
58-
if !strings.HasPrefix(out, "SPEAKER audio 1 ") {
59-
t.Errorf("expected fallback file ID 'audio', got: %q", out)
60-
}
61-
}
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
6211

63-
func min(a, b int) int {
64-
if a < b {
65-
return a
66-
}
67-
return b
68-
}
12+
var _ = Describe("renderRTTM", func() {
13+
It("formats segments as NIST RTTM rows", func() {
14+
r := &schema.DiarizationResult{
15+
Segments: []schema.DiarizationSegment{
16+
{Id: 0, Speaker: "SPEAKER_00", Start: 0, End: 2.34},
17+
{Id: 1, Speaker: "SPEAKER_01", Start: 2.34, End: 4.10},
18+
},
19+
}
20+
out := renderRTTM(r, "/tmp/uploads/meeting.wav")
21+
22+
lines := strings.Split(strings.TrimSpace(out), "\n")
23+
Expect(lines).To(HaveLen(2))
24+
25+
// File ID should be the basename without extension; durations are
26+
// (end - start) with millisecond precision.
27+
Expect(lines[0]).To(HavePrefix("SPEAKER meeting 1 "))
28+
Expect(lines[0]).To(ContainSubstring(" 0.000 2.340 <NA> <NA> SPEAKER_00 <NA> <NA>"))
29+
Expect(lines[1]).To(ContainSubstring(" 2.340 1.760 <NA> <NA> SPEAKER_01 <NA> <NA>"))
30+
})
31+
32+
It("clamps negative duration to zero", func() {
33+
// Backends shouldn't emit end<start, but if they do (clock skew during a
34+
// long pipeline), the RTTM duration must stay non-negative.
35+
r := &schema.DiarizationResult{
36+
Segments: []schema.DiarizationSegment{
37+
{Id: 0, Speaker: "SPEAKER_00", Start: 5, End: 4},
38+
},
39+
}
40+
out := renderRTTM(r, "x.wav")
41+
Expect(out).To(ContainSubstring(" 5.000 0.000 "))
42+
})
43+
44+
It("falls back to 'audio' when the source file name is empty", func() {
45+
r := &schema.DiarizationResult{
46+
Segments: []schema.DiarizationSegment{{Id: 0, Speaker: "SPEAKER_00", Start: 0, End: 1}},
47+
}
48+
out := renderRTTM(r, "")
49+
Expect(out).To(HavePrefix("SPEAKER audio 1 "))
50+
})
51+
})

pkg/grpc/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ func (c *Client) Diarize(ctx context.Context, in *pb.DiarizeRequest, opts ...grp
575575
if err != nil {
576576
return nil, err
577577
}
578-
defer conn.Close()
578+
defer func() { _ = conn.Close() }()
579579
client := pb.NewBackendClient(conn)
580580
return client.Diarize(ctx, in, opts...)
581581
}

tests/e2e/mock_backend_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ var _ = Describe("Mock Backend E2E Tests", Label("MockBackend"), func() {
254254
httpClient := &http.Client{Timeout: 30 * time.Second}
255255
resp, err := httpClient.Do(req)
256256
Expect(err).ToNot(HaveOccurred())
257-
defer resp.Body.Close()
257+
defer func() { _ = resp.Body.Close() }()
258258
data, err := io.ReadAll(resp.Body)
259259
Expect(err).ToNot(HaveOccurred())
260260
return resp, data

0 commit comments

Comments
 (0)