diff --git a/bindings/go/.gitignore b/bindings/go/.gitignore index 20a1b6e48..bfdd3e3c7 100644 --- a/bindings/go/.gitignore +++ b/bindings/go/.gitignore @@ -1,3 +1,4 @@ build models samples/a13.wav +samples/benchmark_out.wav diff --git a/bindings/go/Makefile b/bindings/go/Makefile index c9ab66255..fb57d0fc9 100644 --- a/bindings/go/Makefile +++ b/bindings/go/Makefile @@ -46,6 +46,13 @@ endif examples: $(EXAMPLES_DIR) +benchmark: model-small whisper modtidy +ifeq ($(UNAME_S),Darwin) + @C_INCLUDE_PATH=${INCLUDE_PATH} LIBRARY_PATH=${LIBRARY_PATH} GGML_METAL_PATH_RESOURCES=${GGML_METAL_PATH_RESOURCES} go test -ldflags "-extldflags '$(EXT_LDFLAGS)'" -bench=BenchmarkContextProcess -benchmem -run '^$$' ./pkg/whisper/... +else + @C_INCLUDE_PATH=${INCLUDE_PATH} LIBRARY_PATH=${LIBRARY_PATH} go test -benchmem -run '^$$' ./pkg/whisper/... +endif + model-small: mkdir examples/go-model-download @${BUILD_DIR}/go-model-download -out models ggml-small.en.bin diff --git a/bindings/go/go.mod b/bindings/go/go.mod index 7c92c7b48..5cfd3268a 100644 --- a/bindings/go/go.mod +++ b/bindings/go/go.mod @@ -3,13 +3,13 @@ module github.com/ggerganov/whisper.cpp/bindings/go go 1.23 require ( + github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 github.com/stretchr/testify v1.9.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-audio/audio v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/bindings/go/pkg/whisper/concurrency_gate.go b/bindings/go/pkg/whisper/concurrency_gate.go new file mode 100644 index 000000000..03469a29c --- /dev/null +++ b/bindings/go/pkg/whisper/concurrency_gate.go @@ -0,0 +1,58 @@ +package whisper + +import ( + "sync" + "sync/atomic" + + // Bindings + whisper "github.com/ggerganov/whisper.cpp/bindings/go" +) + +// Gate provides a simple acquire/release contract per key. +// The default implementation is a single-entry lock per key (limit=1). +type Gate interface { + // Acquire returns true if the key was acquired; false if already held + Acquire(key any) bool + // Release releases the key if currently held + Release(key any) +} + +// singleFlightGate is a minimal lock with limit=1 per key +type singleFlightGate struct { + m sync.Map // key -> *int32 (0 available, 1 held) +} + +func (g *singleFlightGate) Acquire(key any) bool { + ptr, _ := g.m.LoadOrStore(key, new(int32)) + busy := ptr.(*int32) + return atomic.CompareAndSwapInt32(busy, 0, 1) +} + +func (g *singleFlightGate) Release(key any) { + if v, ok := g.m.Load(key); ok { + atomic.StoreInt32(v.(*int32), 0) + } +} + +var defaultGate Gate = &singleFlightGate{} + +// SetGate allows applications to override the default gate (e.g., for custom policies) +// Passing nil resets to the default singleFlightGate. +func SetGate(g Gate) { + if g == nil { + defaultGate = &singleFlightGate{} + return + } + defaultGate = g +} + +func gate() Gate { return defaultGate } + +// modelKey derives a stable key per underlying model context for guarding stateless ops +func modelKey(model *ModelContext) *whisper.Context { + if model == nil || model.ctxAccessor() == nil { + return nil + } + ctx, _ := model.ctxAccessor().context() + return ctx +} diff --git a/bindings/go/pkg/whisper/consts.go b/bindings/go/pkg/whisper/consts.go index eab223ce5..fbdd9c310 100644 --- a/bindings/go/pkg/whisper/consts.go +++ b/bindings/go/pkg/whisper/consts.go @@ -20,6 +20,7 @@ var ( ErrUnsupportedLanguage = errors.New("unsupported language") ErrModelNotMultilingual = errors.New("model is not multilingual") ErrModelClosed = errors.Join(errors.New("model has been closed"), ErrInternalAppError) + ErrStatelessBusy = errors.New("stateless context is busy; concurrent processing not supported") // Private errors errParametersRequired = errors.New("parameters are required") diff --git a/bindings/go/pkg/whisper/context_benchmark_test.go b/bindings/go/pkg/whisper/context_benchmark_test.go new file mode 100644 index 000000000..04cf5c897 --- /dev/null +++ b/bindings/go/pkg/whisper/context_benchmark_test.go @@ -0,0 +1,239 @@ +package whisper_test + +import ( + "fmt" + "math" + "os" + "runtime" + "testing" + "time" + + whisper "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper" + "github.com/go-audio/audio" + wav "github.com/go-audio/wav" +) + +// benchProcessVariants runs the common benchmark matrix across context kinds, +// thread sets, and callback modes, for given samples. If singleIteration is true +// it runs only one iteration regardless of b.N. If printTimings is true, +// model timings and custom ms_process metric are reported for NoCallback runs. +func benchProcessVariants( + b *testing.B, + samples []float32, + singleIteration bool, + printTimings bool, + useGPU bool, +) { + threadSets := []uint{1, 2, 4, uint(runtime.NumCPU())} + + device := "cpu" + if useGPU { + device = "gpu" + } + + // Initialize model per device mode + mp := whisper.NewModelContextParams() + mp.SetUseGPU(useGPU) + model, err := whisper.NewModelContextWithParams(ModelPath, mp) + if err != nil { + b.Fatalf("load model (%s): %v", device, err) + } + defer func() { _ = model.Close() }() + + // Context kinds: stateless and stateful + ctxKinds := []struct { + name string + new func() (whisper.Context, error) + }{ + { + name: "stateless", + new: func() (whisper.Context, error) { + params, err := whisper.NewParameters(model, whisper.SAMPLING_GREEDY, func(p *whisper.Parameters) {}) + if err != nil { + return nil, err + } + return whisper.NewStatelessContext(model, params) + }, + }, + { + name: "stateful", + new: func() (whisper.Context, error) { + params, err := whisper.NewParameters(model, whisper.SAMPLING_GREEDY, nil) + if err != nil { + return nil, err + } + return whisper.NewStatefulContext(model, params) + }, + }, + } + + for _, kind := range ctxKinds { + b.Run(device+"/"+kind.name, func(b *testing.B) { + for _, threads := range threadSets { + b.Run(fmt.Sprintf("threads=%d/NoCallback", threads), func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(int64(len(samples) * 4)) + ctx, err := kind.new() + if err != nil { + b.Fatalf("new %s context: %v", kind.name, err) + } + defer func() { _ = ctx.Close() }() + ctx.SetThreads(threads) + + iters := b.N + if singleIteration { + iters = 1 + } + + b.ResetTimer() + for i := 0; i < iters; i++ { + if printTimings { + model.ResetTimings() + } + start := time.Now() + if err := ctx.Process(samples, nil, nil, nil); err != nil { + b.Fatalf("process: %v", err) + } + if printTimings { + elapsed := time.Since(start) + model.PrintTimings() + b.ReportMetric(float64(elapsed.Milliseconds()), "ms_process") + } + } + }) + + b.Run(fmt.Sprintf("threads=%d/WithSegmentCallback", threads), func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(int64(len(samples) * 4)) + ctx, err := kind.new() + if err != nil { + b.Fatalf("new %s context: %v", kind.name, err) + } + defer func() { _ = ctx.Close() }() + ctx.SetThreads(threads) + + iters := b.N + if singleIteration { + iters = 1 + } + + b.ResetTimer() + for i := 0; i < iters; i++ { + start := time.Now() + // Passing a segment callback forces single-segment mode and exercises token extraction + if err := ctx.Process(samples, nil, func(seg whisper.Segment) {}, nil); err != nil { + b.Fatalf("process with callback: %v", err) + } + if printTimings { + elapsed := time.Since(start) + b.ReportMetric(float64(elapsed.Milliseconds()), "ms_process") + } + } + }) + } + }) + } +} + +// BenchmarkContextProcess runs the high-level Context.Process across +// different thread counts, with and without segment callbacks. +func BenchmarkContextProcessCPU(b *testing.B) { + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + b.Skipf("model not found: %s", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + b.Skipf("sample not found: %s", SamplePath) + } + + // Load audio once (reuse helper) + data := helperLoadSample(b, SamplePath) + + benchProcessVariants(b, data, false, true, false) +} + +// BenchmarkContextProcessBig runs one single iteration over a big input +// (the short sample concatenated 10x) to simulate long audio processing. +// This is complementary to BenchmarkContextProcess which runs many iterations +// over the short sample. +func BenchmarkContextProcessBigCPU(b *testing.B) { + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + b.Skipf("model not found: %s", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + b.Skipf("sample not found: %s", SamplePath) + } + + // Load audio once (reuse helper with meta) + data, sampleRate, numChans := helperLoadSampleWithMeta(b, SamplePath) + + // Build big dataset: input concatenated 10x + bigData := make([]float32, len(data)*10) + for i := 0; i < 10; i++ { + copy(bigData[i*len(data):(i+1)*len(data)], data) + } + + // Write the big dataset to a wav file for inspection + outPath := "../../samples/benchmark_out.wav" + fout, err := os.Create(outPath) + if err != nil { + b.Fatalf("create output wav: %v", err) + } + enc := wav.NewEncoder(fout, sampleRate, 16, numChans, 1) + intBuf := &audio.IntBuffer{ + Format: &audio.Format{NumChannels: numChans, SampleRate: sampleRate}, + SourceBitDepth: 16, + Data: make([]int, len(bigData)), + } + for i, s := range bigData { + v := int(math.Round(float64(s) * 32767.0)) + if v > 32767 { + v = 32767 + } else if v < -32768 { + v = -32768 + } + intBuf.Data[i] = v + } + if err := enc.Write(intBuf); err != nil { + _ = fout.Close() + b.Fatalf("encode wav: %v", err) + } + if err := enc.Close(); err != nil { + _ = fout.Close() + b.Fatalf("close encoder: %v", err) + } + _ = fout.Close() + + benchProcessVariants(b, bigData, true, true, false) +} + +// GPU variants reuse model-level GPU enablement via model params +func BenchmarkContextProcessGPU(b *testing.B) { + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + b.Skipf("model not found: %s", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + b.Skipf("sample not found: %s", SamplePath) + } + + data := helperLoadSample(b, SamplePath) + + benchProcessVariants(b, data, false, true, true) +} + +func BenchmarkContextProcessBigGPU(b *testing.B) { + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + b.Skipf("model not found: %s", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + b.Skipf("sample not found: %s", SamplePath) + } + + data, _, _ := helperLoadSampleWithMeta(b, SamplePath) + + bigData := make([]float32, len(data)*10) + for i := 0; i < 10; i++ { + copy(bigData[i*len(data):(i+1)*len(data)], data) + } + + benchProcessVariants(b, bigData, true, true, true) +} diff --git a/bindings/go/pkg/whisper/context_test.go b/bindings/go/pkg/whisper/context_test.go index a56d355ae..f18238ba5 100644 --- a/bindings/go/pkg/whisper/context_test.go +++ b/bindings/go/pkg/whisper/context_test.go @@ -3,11 +3,9 @@ package whisper_test import ( "io" "os" - "sync" "testing" "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper" - "github.com/go-audio/wav" assert "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,115 +13,163 @@ import ( func TestSetLanguage(t *testing.T) { assert := assert.New(t) - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - context, err := model.NewContext() - assert.NoError(err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() - // This returns an error since - // the model 'models/ggml-small.en.bin' - // that is loaded is not multilingual - err = context.SetLanguage("en") - assert.Error(err) + // This returns an error since the small.en model is not multilingual + err := ctx.SetLanguage("en") + assert.Error(err) + }) + } } func TestContextModelIsMultilingual(t *testing.T) { assert := assert.New(t) - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - context, err := model.NewContext() - assert.NoError(err) - - isMultilingual := context.IsMultilingual() - - // This returns false since - // the model 'models/ggml-small.en.bin' - // that is loaded is not multilingual - assert.False(isMultilingual) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() + assert.False(ctx.IsMultilingual()) + }) + } } func TestLanguage(t *testing.T) { assert := assert.New(t) - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - context, err := model.NewContext() - assert.NoError(err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() + expectedLanguage := "en" + actualLanguage := ctx.Language() + assert.Equal(expectedLanguage, actualLanguage) + }) + } +} - // This always returns en since - // the model 'models/ggml-small.en.bin' - // that is loaded is not multilingual - expectedLanguage := "en" - actualLanguage := context.Language() - assert.Equal(expectedLanguage, actualLanguage) +// Generic behavior: Language() and DetectedLanguage() match for both context types +func TestContext_Generic_LanguageAndDetectedLanguage(t *testing.T) { + assert := assert.New(t) + + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } + + data := helperLoadSample(t, SamplePath) + + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() + + langBefore := ctx.Language() + assert.NoError(ctx.Process(data, nil, nil, nil)) + detected := ctx.DetectedLanguage() + assert.Equal(langBefore, detected) + }) + } } func TestProcess(t *testing.T) { assert := assert.New(t) - fh, err := os.Open(SamplePath) - assert.NoError(err) - defer func() { _ = fh.Close() }() + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } - // Decode the WAV file - load the full buffer - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.NoError(err) - assert.Equal(uint16(1), dec.NumChans) + data := helperLoadSample(t, SamplePath) - data := buf.AsFloat32Buffer().Data + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() - - context, err := model.NewContext() - assert.NoError(err) - - err = context.Process(data, nil, nil, nil) - assert.NoError(err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() + err := ctx.Process(data, nil, nil, nil) + assert.NoError(err) + }) + } } func TestDetectedLanguage(t *testing.T) { assert := assert.New(t) - fh, err := os.Open(SamplePath) - assert.NoError(err) - defer func() { _ = fh.Close() }() + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } - // Decode the WAV file - load the full buffer - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.NoError(err) - assert.Equal(uint16(1), dec.NumChans) + data := helperLoadSample(t, SamplePath) - data := buf.AsFloat32Buffer().Data + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() - - context, err := model.NewContext() - assert.NoError(err) - - err = context.Process(data, nil, nil, nil) - assert.NoError(err) - - expectedLanguage := "en" - actualLanguage := context.DetectedLanguage() - assert.Equal(expectedLanguage, actualLanguage) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() + err := ctx.Process(data, nil, nil, nil) + assert.NoError(err) + expectedLanguage := "en" + actualLanguage := ctx.DetectedLanguage() + assert.Equal(expectedLanguage, actualLanguage) + }) + } } // TestContext_ConcurrentProcessing tests that multiple contexts can process concurrently @@ -138,113 +184,29 @@ func TestContext_ConcurrentProcessing(t *testing.T) { t.Skip("Skipping test, sample not found:", SamplePath) } - fh, err := os.Open(SamplePath) - assert.NoError(err) - defer func() { _ = fh.Close() }() + data := helperLoadSample(t, SamplePath) - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.NoError(err) - assert.Equal(uint16(1), dec.NumChans) - data := buf.AsFloat32Buffer().Data - - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() - - ctx, err := model.NewContext() - assert.NoError(err) - assert.NotNil(ctx) - defer func() { _ = ctx.Close() }() - - err = ctx.Process(data, nil, nil, nil) - assert.NoError(err) - - seg, err := ctx.NextSegment() - assert.NoError(err) - assert.NotEmpty(seg.Text) -} - -// TestContext_Parallel_DifferentInputs tests concurrent processing with different inputs -// This validates that each context maintains isolated state for concurrent processing -func TestContext_Parallel_DifferentInputs(t *testing.T) { - assert := assert.New(t) - - if _, err := os.Stat(ModelPath); os.IsNotExist(err) { - t.Skip("Skipping test, model not found:", ModelPath) - } - if _, err := os.Stat(SamplePath); os.IsNotExist(err) { - t.Skip("Skipping test, sample not found:", SamplePath) + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, } - fh, err := os.Open(SamplePath) - assert.NoError(err) - defer func() { _ = fh.Close() }() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.NoError(err) - assert.Equal(uint16(1), dec.NumChans) - data := buf.AsFloat32Buffer().Data - assert.Greater(len(data), 10) + err := ctx.Process(data, nil, nil, nil) + assert.NoError(err) - // Create half-sample (second half) - half := make([]float32, len(data)/2) - copy(half, data[len(data)/2:]) - - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() - - ctx1, err := model.NewContext() - assert.NoError(err) - defer func() { _ = ctx1.Close() }() - ctx2, err := model.NewContext() - assert.NoError(err) - defer func() { _ = ctx2.Close() }() - - // Run in parallel - each context has isolated whisper_state - var wg sync.WaitGroup - var first1, first2 string - var e1, e2 error - - wg.Add(2) - - // No mutex needed because each context is isolated by whisper_state - go func() { - defer wg.Done() - e1 = ctx1.Process(data, nil, nil, nil) - if e1 == nil { - seg, err := ctx1.NextSegment() - if err == nil { - first1 = seg.Text - } else { - e1 = err - } - } - }() - - go func() { - defer wg.Done() - e2 = ctx2.Process(half, nil, nil, nil) - if e2 == nil { - seg, err := ctx2.NextSegment() - if err == nil { - first2 = seg.Text - } else { - e2 = err - } - } - }() - - wg.Wait() - assert.NoError(e1) - assert.NoError(e2) - assert.NotEmpty(first1) - assert.NotEmpty(first2) - assert.NotEqual(first1, first2, "first segments should differ for different inputs") + seg, err := ctx.NextSegment() + assert.NoError(err) + assert.NotEmpty(seg.Text) + }) + } } // TestContext_Close tests that Context.Close() properly frees resources @@ -256,53 +218,72 @@ func TestContext_Close(t *testing.T) { t.Skip("Skipping test, model not found:", ModelPath) } - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) - defer func() { _ = model.Close() }() + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - ctx, err := model.NewContext() - assert.NoError(err) - assert.NotNil(ctx) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() - // Close the context - err = ctx.Close() - require.NoError(t, err) + // Close the context + err := ctx.Close() + require.NoError(t, err) - // Try to use closed context - should return errors - err = ctx.Process([]float32{0.1, 0.2, 0.3}, nil, nil, nil) - require.ErrorIs(t, err, whisper.ErrModelClosed) + // Try to use closed context - should return errors + err = ctx.Process([]float32{0.1, 0.2, 0.3}, nil, nil, nil) + require.ErrorIs(t, err, whisper.ErrModelClosed) + // TODO: remove this logic after deprecating the ErrInternalAppError + require.ErrorIs(t, err, whisper.ErrInternalAppError) - // TODO: remove this logic after deprecating the ErrInternalAppError - require.ErrorIs(t, err, whisper.ErrInternalAppError) + lang := ctx.DetectedLanguage() + require.Empty(t, lang) - lang := ctx.DetectedLanguage() - require.Empty(t, lang) + _, err = ctx.NextSegment() + assert.ErrorIs(err, whisper.ErrModelClosed) + // TODO: remove this logic after deprecating the ErrInternalAppError + assert.ErrorIs(err, whisper.ErrInternalAppError) - _, err = ctx.NextSegment() - assert.ErrorIs(err, whisper.ErrModelClosed) - - // TODO: remove this logic after deprecating the ErrInternalAppError - assert.ErrorIs(err, whisper.ErrInternalAppError) - - // Multiple closes should be safe - err = ctx.Close() - require.NoError(t, err) + // Multiple closes should be safe + err = ctx.Close() + require.NoError(t, err) + }) + } } func Test_Close_Context_of_Closed_Model(t *testing.T) { assert := assert.New(t) - model, err := whisper.New(ModelPath) - assert.NoError(err) - assert.NotNil(model) + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } - ctx, err := model.NewContext() - assert.NoError(err) - assert.NotNil(ctx) + t.Run("stateless", func(t *testing.T) { + model, err := whisper.NewModelContext(ModelPath) + assert.NoError(err) + defer func() { _ = model.Close() }() + params := helperNewParams(t, model, nil) + ctx, err := whisper.NewStatelessContext(model, params) + assert.NoError(err) + require.NoError(t, model.Close()) + require.NoError(t, ctx.Close()) + }) - require.NoError(t, model.Close()) - require.NoError(t, ctx.Close()) + t.Run("stateful", func(t *testing.T) { + model, err := whisper.NewModelContext(ModelPath) + assert.NoError(err) + defer func() { _ = model.Close() }() + params := helperNewParams(t, model, nil) + ctx, err := whisper.NewStatefulContext(model, params) + assert.NoError(err) + require.NoError(t, model.Close()) + require.NoError(t, ctx.Close()) + }) } func TestContext_VAD_And_Diarization_Params_DoNotPanic(t *testing.T) { @@ -315,15 +296,7 @@ func TestContext_VAD_And_Diarization_Params_DoNotPanic(t *testing.T) { t.Skip("Skipping test, sample not found:", SamplePath) } - fh, err := os.Open(SamplePath) - assert.NoError(err) - defer func() { _ = fh.Close() }() - - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.NoError(err) - assert.Equal(uint16(1), dec.NumChans) - data := buf.AsFloat32Buffer().Data + data := helperLoadSample(t, SamplePath) model, err := whisper.NewModelContext(ModelPath) assert.NoError(err) @@ -352,15 +325,7 @@ func TestContext_VAD_And_Diarization_Params_DoNotPanic(t *testing.T) { } func TestDiarization_TwoSpeakers_Boundaries(t *testing.T) { - fh, err := os.Open(MultiSpeakerSamplePath) - require.NoError(t, err) - defer func() { _ = fh.Close() }() - - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.Equal(t, uint16(1), dec.NumChans) - require.NoError(t, err) - data := buf.AsFloat32Buffer().Data + data := helperLoadSample(t, MultiSpeakerSamplePath) model, err := whisper.NewModelContext(ModelTinydiarizePath) require.NoError(t, err) @@ -426,29 +391,193 @@ func TestContext_SpeakerTurnNext_Field_Present(t *testing.T) { t.Skip("Skipping test, sample not found:", SamplePath) } - fh, err := os.Open(SamplePath) - assert.NoError(err) - defer func() { _ = fh.Close() }() + data := helperLoadSample(t, SamplePath) - dec := wav.NewDecoder(fh) - buf, err := dec.FullPCMBuffer() - assert.NoError(err) - assert.Equal(uint16(1), dec.NumChans) - data := buf.AsFloat32Buffer().Data + cases := []struct { + name string + new func(t *testing.T) (whisper.Context, func()) + }{ + {name: "stateless", new: helperNewStatelessContext}, + {name: "stateful", new: helperNewStatefulContext}, + } - model, err := whisper.New(ModelPath) - assert.NoError(err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cleanup := tc.new(t) + defer cleanup() + + err := ctx.Process(data, nil, nil, nil) + assert.NoError(err) + + seg, err := ctx.NextSegment() + assert.NoError(err) + t.Logf("SpeakerTurnNext: %v", seg.SpeakerTurnNext) + _ = seg.SpeakerTurnNext + }) + } +} + +// Ensure Process produces at least one segment for both stateless and stateful contexts +func TestContext_Process_ProducesSegments_BothKinds(t *testing.T) { + assert := assert.New(t) + + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } + + data := helperLoadSample(t, SamplePath) + + // Stateless + stateless, cleanupS := helperNewStatelessContext(t) + defer cleanupS() + require.NoError(t, stateless.Process(data, nil, nil, nil)) + var statelessCount int + for { + _, err := stateless.NextSegment() + if err == io.EOF { + break + } + require.NoError(t, err) + statelessCount++ + } + assert.Greater(statelessCount, 0, "stateless should produce at least one segment") + + // Stateful + stateful, cleanupSt := helperNewStatefulContext(t) + defer cleanupSt() + require.NoError(t, stateful.Process(data, nil, nil, nil)) + var statefulCount int + for { + _, err := stateful.NextSegment() + if err == io.EOF { + break + } + require.NoError(t, err) + statefulCount++ + } + assert.Greater(statefulCount, 0, "stateful should produce at least one segment") +} + +// With temperature=0 (greedy), stateless and stateful should produce identical segments +func TestContext_Process_SameResults_TemperatureZero(t *testing.T) { + assert := assert.New(t) + + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } + + data := helperLoadSample(t, SamplePath) + + // Use a single model to avoid environment differences + model, err := whisper.NewModelContext(ModelPath) + require.NoError(t, err) defer func() { _ = model.Close() }() - ctx, err := model.NewContext() - assert.NoError(err) - defer func() { _ = ctx.Close() }() + // Independent params with temperature=0 for determinism + p := helperNewParams(t, model, func(p *whisper.Parameters) { + p.SetTemperature(0) + p.SetThreads(1) + }) - err = ctx.Process(data, nil, nil, nil) - assert.NoError(err) + stateless, err := whisper.NewStatelessContext(model, p) + require.NoError(t, err) + defer func() { _ = stateless.Close() }() - seg, err := ctx.NextSegment() - assert.NoError(err) - t.Logf("SpeakerTurnNext: %v", seg.SpeakerTurnNext) - _ = seg.SpeakerTurnNext // ensure field exists and is readable + stateful, err := whisper.NewStatefulContext(model, p) + require.NoError(t, err) + defer func() { _ = stateful.Close() }() + + require.NoError(t, stateless.Process(data, nil, nil, nil)) + require.NoError(t, stateful.Process(data, nil, nil, nil)) + + // Collect segment texts + var segsStateless, segsStateful []string + for { + seg, err := stateless.NextSegment() + if err == io.EOF { + break + } + require.NoError(t, err) + segsStateless = append(segsStateless, seg.Text) + } + for { + seg, err := stateful.NextSegment() + if err == io.EOF { + break + } + require.NoError(t, err) + segsStateful = append(segsStateful, seg.Text) + } + + // Both should have at least one segment and be identical + require.Greater(t, len(segsStateless), 0) + require.Greater(t, len(segsStateful), 0) + assert.Equal(len(segsStateful), len(segsStateless)) + for i := range segsStateless { + assert.Equal(segsStateless[i], segsStateful[i], "segment %d text differs", i) + } +} + +// Model.GetTimings: stateless processing updates model timings (non-zero), +// stateful processing does not (zero timings) +func TestModel_GetTimings_Stateless_NonZero_Stateful_Zero(t *testing.T) { + assert := assert.New(t) + + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } + + data := helperLoadSample(t, SamplePath) + + model, err := whisper.NewModelContext(ModelPath) + require.NoError(t, err) + defer func() { _ = model.Close() }() + + // Stateless should produce non-zero timings + t.Run("stateless", func(t *testing.T) { + model.ResetTimings() + params := helperNewParams(t, model, nil) + ctx, err := whisper.NewStatelessContext(model, params) + require.NoError(t, err) + defer func() { _ = ctx.Close() }() + + require.NoError(t, ctx.Process(data, nil, nil, nil)) + + timings, ok := model.GetTimings() + require.True(t, ok, "expected timings to be available after stateless processing") + nonZero := timings.SampleMS > 0 || timings.EncodeMS > 0 || timings.DecodeMS > 0 || timings.BatchdMS > 0 || timings.PromptMS > 0 + assert.True(nonZero, "expected at least one non-zero timing after stateless processing: %#v", timings) + }) + + // Stateful should keep model-level timings at zero + t.Run("stateful", func(t *testing.T) { + model.ResetTimings() + params := helperNewParams(t, model, nil) + ctx, err := whisper.NewStatefulContext(model, params) + require.NoError(t, err) + defer func() { _ = ctx.Close() }() + + require.NoError(t, ctx.Process(data, nil, nil, nil)) + + timings, ok := model.GetTimings() + // Expect timings present but all zero; if not present at all, treat as zero-equivalent + if ok { + assert.Equal(float32(0), timings.SampleMS) + assert.Equal(float32(0), timings.EncodeMS) + assert.Equal(float32(0), timings.DecodeMS) + assert.Equal(float32(0), timings.BatchdMS) + assert.Equal(float32(0), timings.PromptMS) + } else { + t.Log("timings not available for stateful processing; treating as zero") + } + }) } diff --git a/bindings/go/pkg/whisper/log.go b/bindings/go/pkg/whisper/log.go new file mode 100644 index 000000000..66eb0d5c7 --- /dev/null +++ b/bindings/go/pkg/whisper/log.go @@ -0,0 +1,9 @@ +package whisper + +import low "github.com/ggerganov/whisper.cpp/bindings/go" + +// DisableLogs disables all C-side logging from whisper.cpp and ggml. +// Call once early in your program before creating models/contexts. +func DisableLogs() { + low.DisableLogs() +} diff --git a/bindings/go/pkg/whisper/model.go b/bindings/go/pkg/whisper/model.go index a16d6a134..c22490fb8 100644 --- a/bindings/go/pkg/whisper/model.go +++ b/bindings/go/pkg/whisper/model.go @@ -5,7 +5,7 @@ import ( "os" // Bindings - whisper "github.com/ggerganov/whisper.cpp/bindings/go" + low "github.com/ggerganov/whisper.cpp/bindings/go" ) type ModelContext struct { @@ -17,27 +17,50 @@ type ModelContext struct { // Make sure model adheres to the interface var _ Model = (*ModelContext)(nil) +// Timings is a compact, high-level timing snapshot in milliseconds +type Timings struct { + SampleMS float32 + EncodeMS float32 + DecodeMS float32 + BatchdMS float32 + PromptMS float32 +} + // Deprecated: Use NewModelContext instead func New(path string) (Model, error) { return NewModelContext(path) } // NewModelContext creates a new model context + func NewModelContext( path string, +) (*ModelContext, error) { + return NewModelContextWithParams( + path, + NewModelContextParams(), + ) +} + +// NewModelContextWithParams creates a new model context with custom initialization params +func NewModelContextWithParams( + path string, + params ModelContextParams, ) (*ModelContext, error) { model := new(ModelContext) if _, err := os.Stat(path); err != nil { return nil, err - } else if ctx := whisper.Whisper_init(path); ctx == nil { - return nil, ErrUnableToLoadModel - } else { - model.ca = newCtxAccessor(ctx) - model.tokId = newTokenIdentifier(model.ca) - model.path = path } - // Return success + ctx := low.Whisper_init_with_params(path, params.toLow()) + if ctx == nil { + return nil, ErrUnableToLoadModel + } + + model.ca = newCtxAccessor(ctx) + model.tokId = newTokenIdentifier(model.ca) + model.path = path + return model, nil } @@ -75,9 +98,9 @@ func (model *ModelContext) Languages() []string { return nil } - result := make([]string, 0, whisper.Whisper_lang_max_id()) - for i := 0; i < whisper.Whisper_lang_max_id(); i++ { - str := whisper.Whisper_lang_str(i) + result := make([]string, 0, low.Whisper_lang_max_id()) + for i := 0; i < low.Whisper_lang_max_id(); i++ { + str := low.Whisper_lang_str(i) if ctx.Whisper_lang_id(str) >= 0 { result = append(result, str) } @@ -95,8 +118,8 @@ func (model *ModelContext) NewContext() (Context, error) { return nil, err } - // Return new context (now state-backed) - return NewStatefulContext( + // Return new context (stateless for backward compatibility with timings) + return NewStatelessContext( model, params, ) @@ -122,6 +145,35 @@ func (model *ModelContext) ResetTimings() { ctx.Whisper_reset_timings() } +// GetTimings returns a compact snapshot of model-level processing timings. +// +// Behavior notes: +// - Stateless contexts (created via ModelContext.NewContext or NewStatelessContext) +// update model-level timings during Process. After a stateless Process call, +// the returned timings are expected to be non-zero (ok == true). +// - Stateful contexts (created via NewStatefulContext) use a per-state backend +// and do not affect model-level timings. After a stateful Process call, +// the returned timings are expected to be zero values (fields equal 0) or +// the call may return ok == false depending on the underlying implementation. +// +// Use ResetTimings before measurement to clear previous values. +func (model *ModelContext) GetTimings() (Timings, bool) { + ctx, err := model.ca.context() + if err != nil { + return Timings{}, false + } + if t, ok := ctx.Whisper_get_timings_go(); ok { + return Timings{ + SampleMS: t.SampleMS, + EncodeMS: t.EncodeMS, + DecodeMS: t.DecodeMS, + BatchdMS: t.BatchdMS, + PromptMS: t.PromptMS, + }, true + } + return Timings{}, false +} + func (model *ModelContext) tokenIdentifier() *tokenIdentifier { return model.tokId } diff --git a/bindings/go/pkg/whisper/model_context_params.go b/bindings/go/pkg/whisper/model_context_params.go new file mode 100644 index 000000000..62733b9bb --- /dev/null +++ b/bindings/go/pkg/whisper/model_context_params.go @@ -0,0 +1,27 @@ +package whisper + +import ( + low "github.com/ggerganov/whisper.cpp/bindings/go" +) + +type ModelContextParams struct { + p low.ContextParams +} + +func NewModelContextParams() ModelContextParams { + return ModelContextParams{ + p: low.Whisper_context_default_params(), + } +} + +func (p *ModelContextParams) SetUseGPU(v bool) { + p.p.SetUseGPU(v) +} + +func (p *ModelContextParams) SetGPUDevice(n int) { + p.p.SetGPUDevice(n) +} + +func (p *ModelContextParams) toLow() low.ContextParams { + return p.p +} diff --git a/bindings/go/pkg/whisper/context.go b/bindings/go/pkg/whisper/stateful_context.go similarity index 98% rename from bindings/go/pkg/whisper/context.go rename to bindings/go/pkg/whisper/stateful_context.go index 4b178f964..08e04094c 100644 --- a/bindings/go/pkg/whisper/context.go +++ b/bindings/go/pkg/whisper/stateful_context.go @@ -392,4 +392,6 @@ func (context *StatefulContext) SetTranslate(v bool) { context.params.SetTranslate(v) } +// Make stateful context compatible with the old deprecated interface for +// the simple migration into multi-threaded processing. var _ Context = (*StatefulContext)(nil) diff --git a/bindings/go/pkg/whisper/stateful_context_test.go b/bindings/go/pkg/whisper/stateful_context_test.go new file mode 100644 index 000000000..0062aed10 --- /dev/null +++ b/bindings/go/pkg/whisper/stateful_context_test.go @@ -0,0 +1,81 @@ +package whisper_test + +import ( + "os" + "sync" + "testing" + + whisper "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper" + assert "github.com/stretchr/testify/assert" +) + +// Stateful-specific: parallel processing supported +func TestContext_Parallel_DifferentInputs_Stateful(t *testing.T) { + assert := assert.New(t) + + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + if _, err := os.Stat(SamplePath); os.IsNotExist(err) { + t.Skip("Skipping test, sample not found:", SamplePath) + } + + data := helperLoadSample(t, SamplePath) + assert.Greater(len(data), 10) + + // Create half-sample (second half) + half := make([]float32, len(data)/2) + copy(half, data[len(data)/2:]) + + model, err := whisper.NewModelContext(ModelPath) + assert.NoError(err) + defer func() { _ = model.Close() }() + + params1 := helperNewParams(t, model, nil) + params2 := helperNewParams(t, model, nil) + + ctx1, err := whisper.NewStatefulContext(model, params1) + assert.NoError(err) + defer func() { _ = ctx1.Close() }() + ctx2, err := whisper.NewStatefulContext(model, params2) + assert.NoError(err) + defer func() { _ = ctx2.Close() }() + + var wg sync.WaitGroup + var first1, first2 string + var e1, e2 error + wg.Add(2) + + go func() { + defer wg.Done() + e1 = ctx1.Process(data, nil, nil, nil) + if e1 == nil { + seg, err := ctx1.NextSegment() + if err == nil { + first1 = seg.Text + } else { + e1 = err + } + } + }() + + go func() { + defer wg.Done() + e2 = ctx2.Process(half, nil, nil, nil) + if e2 == nil { + seg, err := ctx2.NextSegment() + if err == nil { + first2 = seg.Text + } else { + e2 = err + } + } + }() + + wg.Wait() + assert.NoError(e1) + assert.NoError(e2) + assert.NotEmpty(first1) + assert.NotEmpty(first2) + assert.NotEqual(first1, first2, "first segments should differ for different inputs") +} diff --git a/bindings/go/pkg/whisper/stateless_context.go b/bindings/go/pkg/whisper/stateless_context.go new file mode 100644 index 000000000..7dbe8be29 --- /dev/null +++ b/bindings/go/pkg/whisper/stateless_context.go @@ -0,0 +1,377 @@ +package whisper + +import ( + "fmt" + "io" + "runtime" + "strings" + "time" + + // Bindings + whisper "github.com/ggerganov/whisper.cpp/bindings/go" +) + +type StatelessContext struct { + n int + model *ModelContext + params *Parameters + closed bool +} + +// NewStatelessContext creates a new stateless context backed by the model's context +func NewStatelessContext(model *ModelContext, params *Parameters) (*StatelessContext, error) { + if model == nil { + return nil, errModelRequired + } + + if params == nil { + return nil, errParametersRequired + } + + // Ensure model context is available + if _, err := model.ctxAccessor().context(); err != nil { + return nil, err + } + + c := new(StatelessContext) + c.model = model + c.params = params + + return c, nil +} + +// DetectedLanguage returns the detected language for the current context data +func (context *StatelessContext) DetectedLanguage() string { + if context.closed { + return "" + } + ctx, err := context.model.ctxAccessor().context() + if err != nil { + return "" + } + return whisper.Whisper_lang_str(ctx.Whisper_full_lang_id()) +} + +// Close marks the context as closed. +func (context *StatelessContext) Close() error { + context.closed = true + return nil +} + +// Params returns a high-level parameters wrapper +func (context *StatelessContext) Params() *Parameters { + return context.params +} + +// ResetTimings resets the model performance timing counters. +// Deprecated: Use Model.ResetTimings() instead - these are model-level performance metrics. +func (context *StatelessContext) ResetTimings() { + context.model.ResetTimings() +} + +// PrintTimings prints the model performance timings to stdout. +// Deprecated: Use Model.PrintTimings() instead - these are model-level performance metrics. +func (context *StatelessContext) PrintTimings() { + context.model.PrintTimings() +} + +// SystemInfo returns the system information +func (context *StatelessContext) SystemInfo() string { + return fmt.Sprintf("system_info: n_threads = %d / %d | %s\n", + context.params.Threads(), + runtime.NumCPU(), + whisper.Whisper_print_system_info(), + ) +} + +// Use mel data at offset_ms to try and auto-detect the spoken language +// Make sure to call whisper_pcm_to_mel() or whisper_set_mel() first. +// Returns the probabilities of all languages for this context. +func (context *StatelessContext) WhisperLangAutoDetect(offset_ms int, n_threads int) ([]float32, error) { + if context.closed { + return nil, ErrModelClosed + } + ctx, err := context.model.ctxAccessor().context() + if err != nil { + return nil, err + } + langProbs, err := ctx.Whisper_lang_auto_detect(offset_ms, n_threads) + if err != nil { + return nil, err + } + return langProbs, nil +} + +// Process new sample data and return any errors +func (context *StatelessContext) Process( + data []float32, + callEncoderBegin EncoderBeginCallback, + callNewSegment SegmentCallback, + callProgress ProgressCallback, +) error { + if context.closed { + return ErrModelClosed + } + ctx, err := context.model.ctxAccessor().context() + if err != nil { + return err + } + // Concurrency guard: prevent concurrent stateless processing on shared model ctx + k := modelKey(context.model) + if !gate().Acquire(k) { + return ErrStatelessBusy + } + defer gate().Release(k) + + // If the callback is defined then we force on single_segment mode + if callNewSegment != nil { + context.params.SetSingleSegment(true) + } + + lowLevelParams, err := context.params.unsafeParams() + if err != nil { + return err + } + + if err := ctx.Whisper_full(*lowLevelParams, data, callEncoderBegin, + func(new int) { + if callNewSegment != nil { + num_segments := ctx.Whisper_full_n_segments() + s0 := num_segments - new + for i := s0; i < num_segments; i++ { + callNewSegment(toSegmentFromContext(ctx, i)) + } + } + }, func(progress int) { + if callProgress != nil { + callProgress(progress) + } + }); err != nil { + return err + } + + // Return success + return nil +} + +// NextSegment returns the next segment from the context buffer +func (context *StatelessContext) NextSegment() (Segment, error) { + if context.closed { + return Segment{}, ErrModelClosed + } + ctx, err := context.model.ctxAccessor().context() + if err != nil { + return Segment{}, err + } + + if context.n >= ctx.Whisper_full_n_segments() { + return Segment{}, io.EOF + } + + result := toSegmentFromContext(ctx, context.n) + context.n++ + + return result, nil +} + +func (context *StatelessContext) IsMultilingual() bool { + return context.model.IsMultilingual() +} + +// Token helpers +// Deprecated: Use Model.IsText() instead - token checking is model-specific. +func (context *StatelessContext) IsText(t Token) bool { + result, _ := context.model.tokenIdentifier().IsText(t) + return result +} + +// Deprecated: Use Model.IsBEG() instead - token checking is model-specific. +func (context *StatelessContext) IsBEG(t Token) bool { + result, _ := context.model.tokenIdentifier().IsBEG(t) + return result +} + +// Deprecated: Use Model.IsSOT() instead - token checking is model-specific. +func (context *StatelessContext) IsSOT(t Token) bool { + result, _ := context.model.tokenIdentifier().IsSOT(t) + return result +} + +// Deprecated: Use Model.IsEOT() instead - token checking is model-specific. +func (context *StatelessContext) IsEOT(t Token) bool { + result, _ := context.model.tokenIdentifier().IsEOT(t) + return result +} + +// Deprecated: Use Model.IsPREV() instead - token checking is model-specific. +func (context *StatelessContext) IsPREV(t Token) bool { + result, _ := context.model.tokenIdentifier().IsPREV(t) + return result +} + +// Deprecated: Use Model.IsSOLM() instead - token checking is model-specific. +func (context *StatelessContext) IsSOLM(t Token) bool { + result, _ := context.model.tokenIdentifier().IsSOLM(t) + return result +} + +// Deprecated: Use Model.IsNOT() instead - token checking is model-specific. +func (context *StatelessContext) IsNOT(t Token) bool { + result, _ := context.model.tokenIdentifier().IsNOT(t) + return result +} + +func (context *StatelessContext) SetLanguage(lang string) error { + if context.closed || context.model.ctxAccessor().isClosed() { + return ErrModelClosed + } + + if !context.model.IsMultilingual() { + return ErrModelNotMultilingual + } + + return context.params.SetLanguage(lang) +} + +// Deprecated: Use Model.IsLANG() instead - token checking is model-specific. +func (context *StatelessContext) IsLANG(t Token, lang string) bool { + result, _ := context.model.tokenIdentifier().IsLANG(t, lang) + return result +} + +// Context-backed helper functions +func toSegmentFromContext(ctx *whisper.Context, n int) Segment { + return Segment{ + Num: n, + Text: strings.TrimSpace(ctx.Whisper_full_get_segment_text(n)), + Start: time.Duration(ctx.Whisper_full_get_segment_t0(n)) * time.Millisecond * 10, + End: time.Duration(ctx.Whisper_full_get_segment_t1(n)) * time.Millisecond * 10, + Tokens: toTokensFromContext(ctx, n), + SpeakerTurnNext: false, // speaker turn available only with state-backed accessors + } +} + +func toTokensFromContext(ctx *whisper.Context, n int) []Token { + result := make([]Token, ctx.Whisper_full_n_tokens(n)) + + for i := 0; i < len(result); i++ { + data := ctx.Whisper_full_get_token_data(n, i) + result[i] = Token{ + Id: int(ctx.Whisper_full_get_token_id(n, i)), + Text: ctx.Whisper_full_get_token_text(n, i), + P: ctx.Whisper_full_get_token_p(n, i), + Start: time.Duration(data.T0()) * time.Millisecond * 10, + End: time.Duration(data.T1()) * time.Millisecond * 10, + } + } + + return result +} + +// Deprecated: Use Params().Language() instead +func (context *StatelessContext) Language() string { + return context.params.Language() +} + +// Deprecated: Use Params().SetAudioCtx() instead +func (context *StatelessContext) SetAudioCtx(n uint) { + context.params.SetAudioCtx(n) +} + +// SetBeamSize implements Context. +// Deprecated: Use Params().SetBeamSize() instead +func (context *StatelessContext) SetBeamSize(v int) { + context.params.SetBeamSize(v) +} + +// SetDuration implements Context. +// Deprecated: Use Params().SetDuration() instead +func (context *StatelessContext) SetDuration(v time.Duration) { + context.params.SetDuration(v) +} + +// SetEntropyThold implements Context. +// Deprecated: Use Params().SetEntropyThold() instead +func (context *StatelessContext) SetEntropyThold(v float32) { + context.params.SetEntropyThold(v) +} + +// SetInitialPrompt implements Context. +// Deprecated: Use Params().SetInitialPrompt() instead +func (context *StatelessContext) SetInitialPrompt(v string) { + context.params.SetInitialPrompt(v) +} + +// SetMaxContext implements Context. +// Deprecated: Use Params().SetMaxContext() instead +func (context *StatelessContext) SetMaxContext(v int) { + context.params.SetMaxContext(v) +} + +// SetMaxSegmentLength implements Context. +// Deprecated: Use Params().SetMaxSegmentLength() instead +func (context *StatelessContext) SetMaxSegmentLength(v uint) { + context.params.SetMaxSegmentLength(v) +} + +// SetMaxTokensPerSegment implements Context. +// Deprecated: Use Params().SetMaxTokensPerSegment() instead +func (context *StatelessContext) SetMaxTokensPerSegment(v uint) { + context.params.SetMaxTokensPerSegment(v) +} + +// SetOffset implements Context. +// Deprecated: Use Params().SetOffset() instead +func (context *StatelessContext) SetOffset(v time.Duration) { + context.params.SetOffset(v) +} + +// SetSplitOnWord implements Context. +// Deprecated: Use Params().SetSplitOnWord() instead +func (context *StatelessContext) SetSplitOnWord(v bool) { + context.params.SetSplitOnWord(v) +} + +// SetTemperature implements Context. +// Deprecated: Use Params().SetTemperature() instead +func (context *StatelessContext) SetTemperature(v float32) { + context.params.SetTemperature(v) +} + +// SetTemperatureFallback implements Context. +// Deprecated: Use Params().SetTemperatureFallback() instead +func (context *StatelessContext) SetTemperatureFallback(v float32) { + context.params.SetTemperatureFallback(v) +} + +// SetThreads implements Context. +// Deprecated: Use Params().SetThreads() instead +func (context *StatelessContext) SetThreads(v uint) { + context.params.SetThreads(v) +} + +// SetTokenSumThreshold implements Context. +// Deprecated: Use Params().SetTokenSumThreshold() instead +func (context *StatelessContext) SetTokenSumThreshold(v float32) { + context.params.SetTokenSumThreshold(v) +} + +// SetTokenThreshold implements Context. +// Deprecated: Use Params().SetTokenThreshold() instead +func (context *StatelessContext) SetTokenThreshold(v float32) { + context.params.SetTokenThreshold(v) +} + +// SetTokenTimestamps implements Context. +// Deprecated: Use Params().SetTokenTimestamps() instead +func (context *StatelessContext) SetTokenTimestamps(v bool) { + context.params.SetTokenTimestamps(v) +} + +// SetTranslate implements Context. +// Deprecated: Use Params().SetTranslate() instead +func (context *StatelessContext) SetTranslate(v bool) { + context.params.SetTranslate(v) +} + +var _ Context = (*StatelessContext)(nil) diff --git a/bindings/go/pkg/whisper/stateless_context_test.go b/bindings/go/pkg/whisper/stateless_context_test.go new file mode 100644 index 000000000..0eb867d19 --- /dev/null +++ b/bindings/go/pkg/whisper/stateless_context_test.go @@ -0,0 +1,52 @@ +package whisper_test + +import ( + "sync" + "testing" + + whisper "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper" + assert "github.com/stretchr/testify/assert" +) + +// Ensure stateless contexts cannot process in parallel without isolation +func TestStatelessContext_NotParallelSafe(t *testing.T) { + data := helperLoadSample(t, SamplePath) + + model, closeModel := helperNewModelContext(t) + defer closeModel() + + params := helperNewParams(t, model, nil) + + // Create two stateless contexts sharing the same underlying model context + ctx1, err := whisper.NewStatelessContext(model, params) + assert.NoError(t, err) + defer func() { _ = ctx1.Close() }() + + ctx2, err := whisper.NewStatelessContext(model, params) + assert.NoError(t, err) + defer func() { _ = ctx2.Close() }() + + // Run both in parallel - expect a panic or error from underlying whisper_full + // We capture panics to assert the behavior. + var wg sync.WaitGroup + wg.Add(2) + + var err1, err2 error + + go func() { + defer wg.Done() + err1 = ctx1.Process(data, nil, nil, nil) + }() + + go func() { + defer wg.Done() + err2 = ctx2.Process(data, nil, nil, nil) + }() + + wg.Wait() + + // At least one should return ErrStatelessBusy + if err1 != whisper.ErrStatelessBusy && err2 != whisper.ErrStatelessBusy { + t.Fatalf("expected ErrStatelessBusy when processing in parallel with StatelessContext, got err1=%v err2=%v", err1, err2) + } +} diff --git a/bindings/go/pkg/whisper/test_helpers_test.go b/bindings/go/pkg/whisper/test_helpers_test.go new file mode 100644 index 000000000..15fedc961 --- /dev/null +++ b/bindings/go/pkg/whisper/test_helpers_test.go @@ -0,0 +1,129 @@ +package whisper_test + +import ( + "os" + "testing" + + whisper "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper" + wav "github.com/go-audio/wav" +) + +func helperLoadSample(tb testing.TB, path string) []float32 { + tb.Helper() + fh, err := os.Open(path) + if err != nil { + tb.Fatalf("open sample: %v", err) + } + defer func() { _ = fh.Close() }() + + dec := wav.NewDecoder(fh) + buf, err := dec.FullPCMBuffer() + if err != nil { + tb.Fatalf("decode wav: %v", err) + } + if dec.NumChans != 1 { + tb.Fatalf("expected mono wav, got channels=%d", dec.NumChans) + } + return buf.AsFloat32Buffer().Data +} + +// helperLoadSampleWithMeta loads wav and returns samples with sample rate and channels +func helperLoadSampleWithMeta(tb testing.TB, path string) ([]float32, int, int) { + tb.Helper() + fh, err := os.Open(path) + if err != nil { + tb.Fatalf("open sample: %v", err) + } + defer func() { _ = fh.Close() }() + + dec := wav.NewDecoder(fh) + buf, err := dec.FullPCMBuffer() + if err != nil { + tb.Fatalf("decode wav: %v", err) + } + if dec.NumChans != 1 { + tb.Fatalf("expected mono wav, got channels=%d", dec.NumChans) + } + return buf.AsFloat32Buffer().Data, int(dec.SampleRate), int(dec.NumChans) +} + +func helperNewModel(t *testing.T) (whisper.Model, func()) { + t.Helper() + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + model, err := whisper.New(ModelPath) + if err != nil { + t.Fatalf("load model: %v", err) + } + return model, func() { _ = model.Close() } +} + +func helperNewModelContext(t *testing.T) (*whisper.ModelContext, func()) { + t.Helper() + if _, err := os.Stat(ModelPath); os.IsNotExist(err) { + t.Skip("Skipping test, model not found:", ModelPath) + } + model, err := whisper.NewModelContext(ModelPath) + if err != nil { + t.Fatalf("load model ctx: %v", err) + } + return model, func() { _ = model.Close() } +} + +func helperNewParams(t *testing.T, model *whisper.ModelContext, configure whisper.ParamsConfigure) *whisper.Parameters { + t.Helper() + params, err := whisper.NewParameters(model, whisper.SAMPLING_GREEDY, configure) + if err != nil { + t.Fatalf("new params: %v", err) + } + return params +} + +func helperProcessOnce(t *testing.T, ctx whisper.Context, data []float32) { + t.Helper() + if err := ctx.Process(data, nil, nil, nil); err != nil { + t.Fatalf("process: %v", err) + } +} + +func helperFirstSegmentText(t *testing.T, ctx whisper.Context) string { + t.Helper() + seg, err := ctx.NextSegment() + if err != nil { + t.Fatalf("next segment: %v", err) + } + return seg.Text +} + +// helperNewStatelessContext creates a fresh stateless context and returns a cleanup func +func helperNewStatelessContext(t *testing.T) (whisper.Context, func()) { + t.Helper() + model, closeModel := helperNewModelContext(t) + params := helperNewParams(t, model, nil) + ctx, err := whisper.NewStatelessContext(model, params) + if err != nil { + t.Fatalf("new stateless context: %v", err) + } + cleanup := func() { + _ = ctx.Close() + closeModel() + } + return ctx, cleanup +} + +// helperNewStatefulContext creates a fresh stateful context and returns a cleanup func +func helperNewStatefulContext(t *testing.T) (whisper.Context, func()) { + t.Helper() + model, closeModel := helperNewModelContext(t) + params := helperNewParams(t, model, nil) + ctx, err := whisper.NewStatefulContext(model, params) + if err != nil { + t.Fatalf("new stateful context: %v", err) + } + cleanup := func() { + _ = ctx.Close() + closeModel() + } + return ctx, cleanup +} diff --git a/bindings/go/pkg/whisper/util_test.go b/bindings/go/pkg/whisper/util_test.go index 1b27255ae..a2fadca58 100644 --- a/bindings/go/pkg/whisper/util_test.go +++ b/bindings/go/pkg/whisper/util_test.go @@ -1,8 +1,18 @@ package whisper_test +import ( + "os" + "testing" +) + const ( ModelPath = "../../models/ggml-small.en.bin" ModelTinydiarizePath = "../../models/ggml-small.en-tdrz.bin" SamplePath = "../../samples/jfk.wav" MultiSpeakerSamplePath = "../../samples/a13.wav" ) + +func TestMain(m *testing.M) { + // whisper.DisableLogs() + os.Exit(m.Run()) +} diff --git a/bindings/go/whisper.go b/bindings/go/whisper.go index 83089a26f..023a33d26 100644 --- a/bindings/go/whisper.go +++ b/bindings/go/whisper.go @@ -15,6 +15,7 @@ import ( #cgo darwin LDFLAGS: -lggml-metal -lggml-blas #cgo darwin LDFLAGS: -framework Accelerate -framework Metal -framework Foundation -framework CoreGraphics #include +#include #include extern void callNewSegment(void* user_data, int new); @@ -60,6 +61,22 @@ static struct whisper_full_params whisper_full_default_params_cb(struct whisper_ params.progress_callback_user_data = (void*)(ctx); return params; } + +// Disable all C-side logging (whisper.cpp and ggml) +static void go_cb_log_disable(enum ggml_log_level level, const char * text, void * user_data) { + (void) level; (void) text; (void) user_data; +} + +static void whisper_log_disable_all(void) { + ggml_log_set(go_cb_log_disable, NULL); + whisper_log_set(go_cb_log_disable, NULL); +} + +// Enable default logging (stdout) for whisper.cpp and ggml +static void whisper_log_enable_default(void) { + ggml_log_set(NULL, NULL); + whisper_log_set(NULL, NULL); +} */ import "C" @@ -73,6 +90,8 @@ type ( TokenData C.struct_whisper_token_data SamplingStrategy C.enum_whisper_sampling_strategy Params C.struct_whisper_full_params + Timings C.struct_whisper_timings + ContextParams C.struct_whisper_context_params ) /////////////////////////////////////////////////////////////////////////////// @@ -98,6 +117,12 @@ var ( ErrInvalidLanguage = errors.New("invalid language") ) +// DisableLogs disables all logging coming from the C libraries (whisper.cpp and ggml). +// Call once early in program startup if you want to silence device/backend prints. +func DisableLogs() { + C.whisper_log_disable_all() +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -113,6 +138,36 @@ func Whisper_init(path string) *Context { } } +// Whisper_context_default_params returns default model context params +func Whisper_context_default_params() ContextParams { + return ContextParams(C.whisper_context_default_params()) +} + +// SetUseGPU enables or disables GPU acceleration on the model context (if available) +func (p *ContextParams) SetUseGPU(v bool) { + if v { + p.use_gpu = C.bool(true) + } else { + p.use_gpu = C.bool(false) + } +} + +// SetGPUDevice selects the GPU device index for the model context (CUDA) +func (p *ContextParams) SetGPUDevice(n int) { + p.gpu_device = C.int(n) +} + +// Whisper_init_with_params allocates and initializes a model using custom context params +func Whisper_init_with_params(path string, params ContextParams) *Context { + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + if ctx := C.whisper_init_from_file_with_params(cPath, (C.struct_whisper_context_params)(params)); ctx != nil { + return (*Context)(ctx) + } else { + return nil + } +} + // Frees all memory allocated by the model. func (ctx *Context) Whisper_free() { C.whisper_free((*C.struct_whisper_context)(ctx)) @@ -355,6 +410,32 @@ func (ctx *Context) Whisper_reset_timings() { C.whisper_reset_timings((*C.struct_whisper_context)(ctx)) } +// TimingsGo is a Go-friendly copy of whisper_timings +type TimingsGo struct { + SampleMS float32 + EncodeMS float32 + DecodeMS float32 + BatchdMS float32 + PromptMS float32 +} + +// Whisper_get_timings_go retrieves timing counters and converts them to TimingsGo +func (ctx *Context) Whisper_get_timings_go() (TimingsGo, bool) { + t := C.whisper_get_timings((*C.struct_whisper_context)(ctx)) + if t == nil { + return TimingsGo{}, false + } + // The C struct is 5 consecutive floats; reinterpret and copy + arr := (*[5]C.float)(unsafe.Pointer(t)) + return TimingsGo{ + SampleMS: float32(arr[0]), + EncodeMS: float32(arr[1]), + DecodeMS: float32(arr[2]), + BatchdMS: float32(arr[3]), + PromptMS: float32(arr[4]), + }, true +} + // Print system information func Whisper_print_system_info() string { return C.GoString(C.whisper_print_system_info()) diff --git a/bindings/go/whisper_test.go b/bindings/go/whisper_test.go index 23bbfbff0..330981fb4 100644 --- a/bindings/go/whisper_test.go +++ b/bindings/go/whisper_test.go @@ -19,6 +19,11 @@ const ( SamplePath = "samples/jfk.wav" ) +func TestMain(m *testing.M) { + whisper.DisableLogs() + os.Exit(m.Run()) +} + func Test_Whisper_000(t *testing.T) { assert := assert.New(t) if _, err := os.Stat(ModelPath); os.IsNotExist(err) { diff --git a/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/list b/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/list new file mode 100644 index 000000000..1847373e9 --- /dev/null +++ b/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/list @@ -0,0 +1 @@ +v0.20.0 diff --git a/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/v0.20.0.info b/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/v0.20.0.info new file mode 100644 index 000000000..08057762f --- /dev/null +++ b/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/v0.20.0.info @@ -0,0 +1 @@ +{"Version":"v0.20.0","Time":"2025-07-28T18:28:48Z","Origin":{"VCS":"git","URL":"https://go.googlesource.com/tools","Subdir":"gopls","Hash":"2e31135b736b96cd609904370c71563ce5447826","Ref":"refs/tags/gopls/v0.20.0"}} \ No newline at end of file diff --git a/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/v0.20.0.mod b/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/v0.20.0.mod new file mode 100644 index 000000000..47caff989 --- /dev/null +++ b/pkg/mod/cache/download/golang.org/x/tools/gopls/@v/v0.20.0.mod @@ -0,0 +1,32 @@ +module golang.org/x/tools/gopls + +go 1.24.2 + +require ( + github.com/fatih/gomodifytags v1.17.1-0.20250423142747-f3939df9aa3c + github.com/fsnotify/fsnotify v1.9.0 + github.com/google/go-cmp v0.7.0 + github.com/jba/templatecheck v0.7.1 + golang.org/x/mod v0.26.0 + golang.org/x/sync v0.16.0 + golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b + golang.org/x/text v0.27.0 + golang.org/x/tools v0.35.1-0.20250728180453-01a3475a31bc + golang.org/x/vuln v1.1.4 + gopkg.in/yaml.v3 v3.0.1 + honnef.co/go/tools v0.7.0-0.dev.0.20250523013057-bbc2f4dd71ea + mvdan.cc/gofumpt v0.8.0 + mvdan.cc/xurls/v2 v2.6.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/fatih/camelcase v1.0.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/google/safehtml v0.1.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect +) diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/lookup/golang.org/x/tools/gopls@v0.20.0 b/pkg/mod/cache/download/sumdb/sum.golang.org/lookup/golang.org/x/tools/gopls@v0.20.0 new file mode 100644 index 000000000..fd63ef446 --- /dev/null +++ b/pkg/mod/cache/download/sumdb/sum.golang.org/lookup/golang.org/x/tools/gopls@v0.20.0 @@ -0,0 +1,9 @@ +41328958 +golang.org/x/tools/gopls v0.20.0 h1:fxOYZXKl6IsOTKIh6IgjDbIDHlr5btOtOUkrGOgFDB4= +golang.org/x/tools/gopls v0.20.0/go.mod h1:vxYUZ8l4swjbvTQJJONmVfbHsd1ovixCwB7sodBbTYI= + +go.sum database tree +43548952 +nX6jrsdthQ8kDPrwxKP2h/3CAC+o/Tzl00DK+QUiDxE= + +— sum.golang.org Az3grtVCRqi+V2+TLDpRvXhgZDzixz81eDxCTse8HVQFKkxvm3+CBHWwrkincl2+LzuJetgKkMzjLg5M1SI/XmJT7AQ= diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/0/x161/441 b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/0/x161/441 new file mode 100644 index 000000000..20eff0ee6 Binary files /dev/null and b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/0/x161/441 differ diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/0/x170/113.p/24 b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/0/x170/113.p/24 new file mode 100644 index 000000000..01d9b6eed Binary files /dev/null and b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/0/x170/113.p/24 differ diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/1/630 b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/1/630 new file mode 100644 index 000000000..e2c434fd0 Binary files /dev/null and b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/1/630 differ diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/1/664.p/129 b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/1/664.p/129 new file mode 100644 index 000000000..ff565c04c Binary files /dev/null and b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/1/664.p/129 differ diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/2/002.p/152 b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/2/002.p/152 new file mode 100644 index 000000000..fbfb0682e Binary files /dev/null and b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/2/002.p/152 differ diff --git a/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/3/000.p/2 b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/3/000.p/2 new file mode 100644 index 000000000..948d7faa7 --- /dev/null +++ b/pkg/mod/cache/download/sumdb/sum.golang.org/tile/8/3/000.p/2 @@ -0,0 +1,3 @@ +o +@m%qT `ow!^Z{ˍv51 "sJYF  +i9Z9 \ No newline at end of file diff --git a/pkg/sumdb/sum.golang.org/latest b/pkg/sumdb/sum.golang.org/latest new file mode 100644 index 000000000..65fabf8ca --- /dev/null +++ b/pkg/sumdb/sum.golang.org/latest @@ -0,0 +1,5 @@ +go.sum database tree +43548952 +nX6jrsdthQ8kDPrwxKP2h/3CAC+o/Tzl00DK+QUiDxE= + +— sum.golang.org Az3grtVCRqi+V2+TLDpRvXhgZDzixz81eDxCTse8HVQFKkxvm3+CBHWwrkincl2+LzuJetgKkMzjLg5M1SI/XmJT7AQ=