Merge branch 'feat/cef-linux-audio-output' into feat/cef-engine-adapter
39 files · +2622 -474
M go.mod +3 -0
| @@ -5,6 +5,7 @@ go 1.26.1 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego-cef v0.8.0 | |
| 7 | 7 | github.com/bnema/purego-ffmpeg v0.3.0 | |
| 8 | + | github.com/bnema/purego-pipewire v0.0.0-00010101000000-000000000000 | |
| 8 | 9 | github.com/bnema/purego-sqlite v0.1.1 | |
| 9 | 10 | github.com/bnema/puregotk v0.3.0 | |
| 10 | 11 | github.com/bnema/puregotk-webkit v0.0.16 | |
| @@ -30,6 +31,8 @@ replace github.com/bnema/purego-cef => ../purego-cef | |||
| 30 | 31 | ||
| 31 | 32 | replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg | |
| 32 | 33 | ||
| 34 | + | replace github.com/bnema/purego-pipewire => ../../../purego-pipewire/.worktrees/dumber-cef-audio | |
| 35 | + | ||
| 33 | 36 | require ( | |
| 34 | 37 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 35 | 38 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
A internal/application/port/audio_output.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // Package port defines application-layer interfaces for external capabilities. | |
| 2 | + | // This file defines the Audio Output port for CEF audio streaming on Linux. | |
| 3 | + | package port | |
| 4 | + | ||
| 5 | + | import "context" | |
| 6 | + | ||
| 7 | + | // AudioStreamFormat represents the audio stream format configuration. | |
| 8 | + | // This is a value object that describes how audio data should be streamed. | |
| 9 | + | type AudioStreamFormat struct { | |
| 10 | + | // SampleRate is the audio sample rate in Hz (e.g., 48000, 44100). | |
| 11 | + | SampleRate int | |
| 12 | + | // ChannelCount is the number of audio channels (e.g., 1 for mono, 2 for stereo). | |
| 13 | + | ChannelCount int | |
| 14 | + | // FramesPerBuffer is the number of frames per buffer (e.g., 512, 1024). | |
| 15 | + | FramesPerBuffer int | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // AudioOutputStream represents an active audio output stream. | |
| 19 | + | // Implementations handle the low-level audio playback. | |
| 20 | + | type AudioOutputStream interface { | |
| 21 | + | // Write sends audio samples to the output device. | |
| 22 | + | // Samples are provided as a 2D slice: [channel][frame]float32. | |
| 23 | + | // This matches CEF's planar float32 audio format. | |
| 24 | + | Write(samples [][]float32) error | |
| 25 | + | // Close releases the audio stream and associated resources. | |
| 26 | + | Close() error | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // AudioOutputFactory creates audio output streams. | |
| 30 | + | // This is the primary interface for the audio output port. | |
| 31 | + | type AudioOutputFactory interface { | |
| 32 | + | // NewStream creates a new audio output stream with the given format. | |
| 33 | + | NewStream(ctx context.Context, format AudioStreamFormat) (AudioOutputStream, error) | |
| 34 | + | } | |
A internal/application/port/audio_output_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package port_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // TestAudioOutputPortContract verifies that the Audio Output port contract | |
| 12 | + | // matches the approved design exactly. | |
| 13 | + | func TestAudioOutputPortContract(t *testing.T) { | |
| 14 | + | t.Run("AudioStreamFormat is a struct with correct fields", func(t *testing.T) { | |
| 15 | + | format := port.AudioStreamFormat{ | |
| 16 | + | SampleRate: 48000, | |
| 17 | + | ChannelCount: 2, | |
| 18 | + | FramesPerBuffer: 512, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | assert.Equal(t, 48000, format.SampleRate) | |
| 22 | + | assert.Equal(t, 2, format.ChannelCount) | |
| 23 | + | assert.Equal(t, 512, format.FramesPerBuffer) | |
| 24 | + | }) | |
| 25 | + | ||
| 26 | + | t.Run("AudioOutputStream interface has Write with [][]float32", func(_ *testing.T) { | |
| 27 | + | var _ port.AudioOutputStream = (*mockAudioOutputStream)(nil) | |
| 28 | + | }) | |
| 29 | + | ||
| 30 | + | t.Run("AudioOutputFactory has NewStream with context and format", func(_ *testing.T) { | |
| 31 | + | var _ port.AudioOutputFactory = (*mockAudioOutputFactory)(nil) | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // mockAudioOutputStream is a test mock for the AudioOutputStream interface | |
| 36 | + | type mockAudioOutputStream struct{} | |
| 37 | + | ||
| 38 | + | func (*mockAudioOutputStream) Write(_ [][]float32) error { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (*mockAudioOutputStream) Close() error { | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // mockAudioOutputFactory is a test mock for the AudioOutputFactory interface | |
| 47 | + | type mockAudioOutputFactory struct{} | |
| 48 | + | ||
| 49 | + | func (*mockAudioOutputFactory) NewStream(_ context.Context, _ port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 50 | + | return &mockAudioOutputStream{}, nil | |
| 51 | + | } | |
M internal/bootstrap/engine.go +3 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 9 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| @@ -42,7 +43,8 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 42 | 43 | ) | |
| 43 | 44 | case config.EngineTypeCEF: | |
| 44 | 45 | cefCfg := cfg.Engine.CEF | |
| 45 | - | return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding) | |
| 46 | + | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 47 | + | return cef.NewEngine(input.Ctx, cefCfg, cfg.Transcoding, audioFactory) | |
| 46 | 48 | default: | |
| 47 | 49 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 48 | 50 | } | |
M internal/bootstrap/handler_deps.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | // BuildHandlerDeps constructs handler dependencies from the config manager. | |
| 13 | 13 | // It combines keybinding use cases and config save into a single struct. | |
| 14 | 14 | // The context is accepted for future use (e.g., passing to constructors that may need cancellation). | |
| 15 | - | func BuildHandlerDeps(ctx context.Context) (*port.HandlerDeps, error) { | |
| 15 | + | func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) { | |
| 16 | 16 | mgr := config.GetManager() | |
| 17 | 17 | if mgr == nil { | |
| 18 | 18 | return nil, fmt.Errorf("config manager not initialized") | |
A internal/infrastructure/audio/factory/output.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | // Package factory provides audio backend selection with fallback support. | |
| 2 | + | package factory | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/audio/null" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/audio/pipewire" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Selector chooses between a primary and fallback audio backend. | |
| 15 | + | // If the primary backend fails to create a stream, it falls back to the fallback. | |
| 16 | + | type Selector struct { | |
| 17 | + | Primary port.AudioOutputFactory | |
| 18 | + | Fallback port.AudioOutputFactory | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewStream attempts to create a stream from the primary backend. | |
| 22 | + | // If that fails, it falls back to the fallback backend. | |
| 23 | + | // Returns an error only if both backends fail. | |
| 24 | + | func (s *Selector) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 25 | + | if s.Primary != nil { | |
| 26 | + | stream, err := s.Primary.NewStream(ctx, format) | |
| 27 | + | if err == nil { | |
| 28 | + | return stream, nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Primary failed, try fallback | |
| 32 | + | if s.Fallback != nil { | |
| 33 | + | fallbackStream, fallbackErr := s.Fallback.NewStream(ctx, format) | |
| 34 | + | if fallbackErr == nil { | |
| 35 | + | return fallbackStream, nil | |
| 36 | + | } | |
| 37 | + | return nil, fmt.Errorf("audio backends failed: %w", errors.Join(err, fallbackErr)) | |
| 38 | + | } | |
| 39 | + | return nil, fmt.Errorf("primary failed: %w; no fallback configured", err) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // No primary, try fallback only | |
| 43 | + | if s.Fallback != nil { | |
| 44 | + | return s.Fallback.NewStream(ctx, format) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | return nil, errors.New("no audio backend configured") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // NewAudioOutputFactory creates the primary audio output factory with PipeWire | |
| 51 | + | // as the primary backend and null as the fallback. | |
| 52 | + | // If PipeWire stream creation fails at runtime, the Selector falls back to the | |
| 53 | + | // null backend automatically. | |
| 54 | + | func NewAudioOutputFactory() port.AudioOutputFactory { | |
| 55 | + | pwFactory, err := pipewire.NewFactory() | |
| 56 | + | if err != nil { | |
| 57 | + | // Unexpected failure; use null fallback only | |
| 58 | + | return &null.Factory{} | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | return &Selector{ | |
| 62 | + | Primary: pwFactory, | |
| 63 | + | Fallback: &null.Factory{}, | |
| 64 | + | } | |
| 65 | + | } | |
A internal/infrastructure/audio/factory/output_test.go +172 -0
| @@ -0,0 +1,172 @@ | |||
| 1 | + | package factory | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/audio/null" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // failingFactory is a factory that always returns an error | |
| 13 | + | type failingFactory struct { | |
| 14 | + | err error | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (f *failingFactory) NewStream(_ context.Context, _ port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 18 | + | return nil, f.err | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestSelector_PrimaryFails_FallsBackToFallback(t *testing.T) { | |
| 22 | + | // Arrange | |
| 23 | + | primaryErr := errors.New("primary backend unavailable") | |
| 24 | + | primary := &failingFactory{err: primaryErr} | |
| 25 | + | fallback := &null.Factory{} | |
| 26 | + | ||
| 27 | + | selector := &Selector{ | |
| 28 | + | Primary: primary, | |
| 29 | + | Fallback: fallback, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | format := port.AudioStreamFormat{ | |
| 33 | + | SampleRate: 48000, | |
| 34 | + | ChannelCount: 2, | |
| 35 | + | FramesPerBuffer: 512, | |
| 36 | + | } | |
| 37 | + | ctx := context.Background() | |
| 38 | + | ||
| 39 | + | // Act | |
| 40 | + | stream, err := selector.NewStream(ctx, format) | |
| 41 | + | ||
| 42 | + | // Assert | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatalf("Expected no error when fallback succeeds, got: %v", err) | |
| 45 | + | } | |
| 46 | + | if stream == nil { | |
| 47 | + | t.Fatal("Expected non-nil stream from fallback") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Verify the stream works (it's the null stream) | |
| 51 | + | samples := make([][]float32, format.ChannelCount) | |
| 52 | + | for i := range samples { | |
| 53 | + | samples[i] = make([]float32, format.FramesPerBuffer) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | if err := stream.Write(samples); err != nil { | |
| 57 | + | t.Errorf("Write failed: %v", err) | |
| 58 | + | } | |
| 59 | + | if err := stream.Close(); err != nil { | |
| 60 | + | t.Errorf("Close failed: %v", err) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestSelector_PrimarySucceeds_UsesPrimary(t *testing.T) { | |
| 65 | + | // Arrange | |
| 66 | + | primary := &null.Factory{} // Using null as primary for simplicity | |
| 67 | + | fallback := &failingFactory{err: errors.New("fallback should not be called")} | |
| 68 | + | ||
| 69 | + | selector := &Selector{ | |
| 70 | + | Primary: primary, | |
| 71 | + | Fallback: fallback, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | format := port.AudioStreamFormat{ | |
| 75 | + | SampleRate: 48000, | |
| 76 | + | ChannelCount: 2, | |
| 77 | + | FramesPerBuffer: 512, | |
| 78 | + | } | |
| 79 | + | ctx := context.Background() | |
| 80 | + | ||
| 81 | + | // Act | |
| 82 | + | stream, err := selector.NewStream(ctx, format) | |
| 83 | + | ||
| 84 | + | // Assert | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatalf("Expected no error, got: %v", err) | |
| 87 | + | } | |
| 88 | + | if stream == nil { | |
| 89 | + | t.Fatal("Expected non-nil stream from primary") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Verify the stream works | |
| 93 | + | samples := make([][]float32, format.ChannelCount) | |
| 94 | + | for i := range samples { | |
| 95 | + | samples[i] = make([]float32, format.FramesPerBuffer) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | if err := stream.Write(samples); err != nil { | |
| 99 | + | t.Errorf("Write failed: %v", err) | |
| 100 | + | } | |
| 101 | + | if err := stream.Close(); err != nil { | |
| 102 | + | t.Errorf("Close failed: %v", err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestSelector_BothFail_ReturnsError(t *testing.T) { | |
| 107 | + | // Arrange | |
| 108 | + | primaryErr := errors.New("primary backend unavailable") | |
| 109 | + | fallbackErr := errors.New("fallback backend unavailable") | |
| 110 | + | ||
| 111 | + | selector := &Selector{ | |
| 112 | + | Primary: &failingFactory{err: primaryErr}, | |
| 113 | + | Fallback: &failingFactory{err: fallbackErr}, | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | format := port.AudioStreamFormat{ | |
| 117 | + | SampleRate: 48000, | |
| 118 | + | ChannelCount: 2, | |
| 119 | + | FramesPerBuffer: 512, | |
| 120 | + | } | |
| 121 | + | ctx := context.Background() | |
| 122 | + | ||
| 123 | + | // Act | |
| 124 | + | stream, err := selector.NewStream(ctx, format) | |
| 125 | + | ||
| 126 | + | // Assert | |
| 127 | + | if err == nil { | |
| 128 | + | t.Fatal("Expected error when both backends fail") | |
| 129 | + | } | |
| 130 | + | if stream != nil { | |
| 131 | + | t.Error("Expected nil stream when both backends fail") | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // TestNewAudioOutputFactory_ReturnsWorkingFactory verifies that | |
| 136 | + | // NewAudioOutputFactory returns a working Selector with PipeWire primary | |
| 137 | + | // and null fallback. | |
| 138 | + | func TestNewAudioOutputFactory_ReturnsWorkingFactory(t *testing.T) { | |
| 139 | + | factory := NewAudioOutputFactory() | |
| 140 | + | if factory == nil { | |
| 141 | + | t.Fatal("Expected non-nil factory") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Verify the factory produces a working stream | |
| 145 | + | format := port.AudioStreamFormat{ | |
| 146 | + | SampleRate: 48000, | |
| 147 | + | ChannelCount: 2, | |
| 148 | + | FramesPerBuffer: 512, | |
| 149 | + | } | |
| 150 | + | ctx := context.Background() | |
| 151 | + | ||
| 152 | + | stream, err := factory.NewStream(ctx, format) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatalf("Expected no error creating stream, got: %v", err) | |
| 155 | + | } | |
| 156 | + | if stream == nil { | |
| 157 | + | t.Fatal("Expected non-nil stream") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // Verify the stream works | |
| 161 | + | samples := make([][]float32, format.ChannelCount) | |
| 162 | + | for i := range samples { | |
| 163 | + | samples[i] = make([]float32, format.FramesPerBuffer) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | if err := stream.Write(samples); err != nil { | |
| 167 | + | t.Errorf("Write failed: %v", err) | |
| 168 | + | } | |
| 169 | + | if err := stream.Close(); err != nil { | |
| 170 | + | t.Errorf("Close failed: %v", err) | |
| 171 | + | } | |
| 172 | + | } | |
A internal/infrastructure/audio/null/output.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | // Package null provides a null audio backend that discards all audio data. | |
| 2 | + | // This is useful as a fallback when no real audio backend is available. | |
| 3 | + | package null | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Factory creates null audio output streams that discard all audio data. | |
| 12 | + | type Factory struct{} | |
| 13 | + | ||
| 14 | + | // NewStream creates a new null audio output stream. | |
| 15 | + | // The stream accepts writes but discards all data silently. | |
| 16 | + | func (*Factory) NewStream(_ context.Context, _ port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 17 | + | return &Stream{}, nil | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Stream is a null audio output stream that discards all audio data. | |
| 21 | + | type Stream struct{} | |
| 22 | + | ||
| 23 | + | // Write accepts audio samples but discards them silently. | |
| 24 | + | // This method never returns an error. | |
| 25 | + | func (*Stream) Write(_ [][]float32) error { | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Close releases the audio stream. | |
| 30 | + | // This method is idempotent and never returns an error. | |
| 31 | + | func (*Stream) Close() error { | |
| 32 | + | return nil | |
| 33 | + | } | |
A internal/infrastructure/audio/null/output_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package null | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFactory_NewStream_ReturnsWritableClosableStream(t *testing.T) { | |
| 11 | + | // Arrange | |
| 12 | + | factory := &Factory{} | |
| 13 | + | format := port.AudioStreamFormat{ | |
| 14 | + | SampleRate: 48000, | |
| 15 | + | ChannelCount: 2, | |
| 16 | + | FramesPerBuffer: 512, | |
| 17 | + | } | |
| 18 | + | ctx := context.Background() | |
| 19 | + | ||
| 20 | + | // Act | |
| 21 | + | stream, err := factory.NewStream(ctx, format) | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatalf("NewStream failed: %v", err) | |
| 24 | + | } | |
| 25 | + | if stream == nil { | |
| 26 | + | t.Fatal("NewStream returned nil stream") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Assert: stream should accept writes | |
| 30 | + | samples := make([][]float32, format.ChannelCount) | |
| 31 | + | for i := range samples { | |
| 32 | + | samples[i] = make([]float32, format.FramesPerBuffer) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | if err := stream.Write(samples); err != nil { | |
| 36 | + | t.Errorf("Write failed: %v", err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Assert: stream should be closable | |
| 40 | + | if err := stream.Close(); err != nil { | |
| 41 | + | t.Errorf("Close failed: %v", err) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestStream_Write_AcceptsMultipleCalls(t *testing.T) { | |
| 46 | + | // Arrange | |
| 47 | + | factory := &Factory{} | |
| 48 | + | format := port.AudioStreamFormat{ | |
| 49 | + | SampleRate: 44100, | |
| 50 | + | ChannelCount: 2, | |
| 51 | + | FramesPerBuffer: 1024, | |
| 52 | + | } | |
| 53 | + | ctx := context.Background() | |
| 54 | + | ||
| 55 | + | stream, err := factory.NewStream(ctx, format) | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatalf("NewStream failed: %v", err) | |
| 58 | + | } | |
| 59 | + | defer stream.Close() | |
| 60 | + | ||
| 61 | + | samples := make([][]float32, format.ChannelCount) | |
| 62 | + | for i := range samples { | |
| 63 | + | samples[i] = make([]float32, format.FramesPerBuffer) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Act & Assert: multiple writes should succeed | |
| 67 | + | for i := 0; i < 3; i++ { | |
| 68 | + | if err := stream.Write(samples); err != nil { | |
| 69 | + | t.Errorf("Write #%d failed: %v", i+1, err) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestStream_Close_Idempotent(t *testing.T) { | |
| 75 | + | // Arrange | |
| 76 | + | factory := &Factory{} | |
| 77 | + | format := port.AudioStreamFormat{ | |
| 78 | + | SampleRate: 48000, | |
| 79 | + | ChannelCount: 1, | |
| 80 | + | FramesPerBuffer: 256, | |
| 81 | + | } | |
| 82 | + | ctx := context.Background() | |
| 83 | + | ||
| 84 | + | stream, err := factory.NewStream(ctx, format) | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatalf("NewStream failed: %v", err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Act & Assert: multiple closes should succeed | |
| 90 | + | if err := stream.Close(); err != nil { | |
| 91 | + | t.Errorf("First Close failed: %v", err) | |
| 92 | + | } | |
| 93 | + | if err := stream.Close(); err != nil { | |
| 94 | + | t.Errorf("Second Close failed: %v", err) | |
| 95 | + | } | |
| 96 | + | } | |
A internal/infrastructure/audio/pipewire/output.go +280 -0
| @@ -0,0 +1,280 @@ | |||
| 1 | + | // Package pipewire provides a PipeWire-based audio output implementation. | |
| 2 | + | // This wraps purego-pipewire's high-level Player API. | |
| 3 | + | package pipewire | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | ||
| 12 | + | pwpipewire "github.com/bnema/purego-pipewire/pipewire" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | "github.com/bnema/dumber/internal/logging" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Compile-time interface checks. | |
| 19 | + | var ( | |
| 20 | + | _ port.AudioOutputFactory = (*Factory)(nil) | |
| 21 | + | _ port.AudioOutputStream = (*playerStreamAdapter)(nil) | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // ErrInvalidFormat is returned when the audio stream format is invalid. | |
| 25 | + | var ErrInvalidFormat = errors.New("invalid audio stream format") | |
| 26 | + | ||
| 27 | + | // ErrStreamClosed is returned when writing to a closed stream. | |
| 28 | + | var ErrStreamClosed = errors.New("audio stream closed") | |
| 29 | + | ||
| 30 | + | // playerCreator is a function type that creates a Player from config and callbacks. | |
| 31 | + | // This exists to allow dependency injection in tests. | |
| 32 | + | type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) | |
| 33 | + | ||
| 34 | + | // Factory creates PipeWire audio output streams. | |
| 35 | + | // Each call to NewStream creates an independent purego-pipewire Player. | |
| 36 | + | type Factory struct { | |
| 37 | + | createPlayer playerCreator | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // NewFactory creates a new PipeWire audio factory. | |
| 41 | + | // The factory is stateless; players are created per-stream. | |
| 42 | + | func NewFactory() (*Factory, error) { | |
| 43 | + | return &Factory{ | |
| 44 | + | createPlayer: pwpipewire.NewPlayer, | |
| 45 | + | }, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // NewStream creates a new audio output stream with the given format. | |
| 49 | + | // It validates the Dumber format, creates a purego-pipewire Player configured | |
| 50 | + | // from the format, and returns a push-to-pull adapter that bridges Write() | |
| 51 | + | // calls into the Player's Fill callback. | |
| 52 | + | func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 53 | + | // Check context cancellation | |
| 54 | + | if ctx.Err() != nil { | |
| 55 | + | return nil, ctx.Err() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Validate format | |
| 59 | + | if err := validateFormat(format); err != nil { | |
| 60 | + | return nil, err | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | log := logging.FromContext(ctx) | |
| 64 | + | log.Info(). | |
| 65 | + | Int("sample_rate", format.SampleRate). | |
| 66 | + | Int("channels", format.ChannelCount). | |
| 67 | + | Int("frames_per_buffer", format.FramesPerBuffer). | |
| 68 | + | Msg("pipewire: creating player") | |
| 69 | + | ||
| 70 | + | // Create the adapter with its ring buffer before creating the player, | |
| 71 | + | // because the Fill callback captures the adapter. | |
| 72 | + | adapter := &playerStreamAdapter{ | |
| 73 | + | ctx: ctx, | |
| 74 | + | buf: make(chan [][]float32, 4), // small buffer for push→pull bridging | |
| 75 | + | closed: make(chan struct{}), | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | config := pwpipewire.PlayerConfig{ | |
| 79 | + | SampleRate: format.SampleRate, | |
| 80 | + | Channels: format.ChannelCount, | |
| 81 | + | FramesPerBuffer: format.FramesPerBuffer, | |
| 82 | + | SampleFormat: pwpipewire.SampleFormatF32, | |
| 83 | + | UnderrunPolicy: pwpipewire.UnderrunFillSilence, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | callbacks := pwpipewire.PlayerCallbacks{ | |
| 87 | + | Fill: adapter.fill, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | player, err := f.createPlayer(config, callbacks) | |
| 91 | + | if err != nil { | |
| 92 | + | log.Warn().Err(err).Msg("pipewire: failed to create player") | |
| 93 | + | return nil, fmt.Errorf("failed to create pipewire player: %w", err) | |
| 94 | + | } | |
| 95 | + | adapter.player = player | |
| 96 | + | ||
| 97 | + | // Start the player so the Fill callback begins being called. | |
| 98 | + | if err := player.Start(); err != nil { | |
| 99 | + | _ = player.Close() | |
| 100 | + | log.Warn().Err(err).Msg("pipewire: failed to start player") | |
| 101 | + | return nil, fmt.Errorf("failed to start pipewire player: %w", err) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | log.Info().Msg("pipewire: player started successfully") | |
| 105 | + | ||
| 106 | + | return adapter, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // validateFormat checks if the audio format parameters are valid. | |
| 110 | + | func validateFormat(format port.AudioStreamFormat) error { | |
| 111 | + | // Sample rate must be reasonable (8kHz to 384kHz) | |
| 112 | + | if format.SampleRate < 8000 || format.SampleRate > 384000 { | |
| 113 | + | return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384000]", | |
| 114 | + | ErrInvalidFormat, format.SampleRate) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Channel count must be 1 to 1024 (reasonable upper limit) | |
| 118 | + | if format.ChannelCount < 1 || format.ChannelCount > 1024 { | |
| 119 | + | return fmt.Errorf("%w: channel count %d out of range [1, 1024]", | |
| 120 | + | ErrInvalidFormat, format.ChannelCount) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Frames per buffer must be positive and reasonable | |
| 124 | + | if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 { | |
| 125 | + | return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", | |
| 126 | + | ErrInvalidFormat, format.FramesPerBuffer) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // playerStreamAdapter bridges Dumber's push-style Write(samples) interface | |
| 133 | + | // to purego-pipewire's pull-style Fill callback. | |
| 134 | + | // | |
| 135 | + | // Write() sends sample buffers into a channel. The Fill callback drains them | |
| 136 | + | // into the PCMBuffer that purego-pipewire passes in. If no data is available, | |
| 137 | + | // the player's UnderrunFillSilence policy handles it. | |
| 138 | + | type playerStreamAdapter struct { | |
| 139 | + | player pwpipewire.Player | |
| 140 | + | ctx context.Context | |
| 141 | + | buf chan [][]float32 // push→pull handoff channel | |
| 142 | + | ||
| 143 | + | closeOnce sync.Once | |
| 144 | + | closed chan struct{} // closed when Close is called | |
| 145 | + | ||
| 146 | + | // Diagnostic counters (atomic, no mutex needed). | |
| 147 | + | fillCount atomic.Uint64 // total Fill callbacks | |
| 148 | + | underrunCount atomic.Uint64 // Fill callbacks with no data available | |
| 149 | + | dropCount atomic.Uint64 // Write calls that dropped data (buffer full) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Write sends audio samples to the PipeWire output. | |
| 153 | + | // Samples are provided as [channel][frame]float32 matching CEF's planar format. | |
| 154 | + | // If the internal buffer is full the packet is dropped silently so the caller | |
| 155 | + | // (CEF audio callback thread) is never blocked. | |
| 156 | + | // Safe for concurrent use with Close. | |
| 157 | + | func (a *playerStreamAdapter) Write(samples [][]float32) error { | |
| 158 | + | // Fast path: already closed. | |
| 159 | + | select { | |
| 160 | + | case <-a.closed: | |
| 161 | + | return ErrStreamClosed | |
| 162 | + | default: | |
| 163 | + | } | |
| 164 | + | // Non-blocking send: drop packet when buffer is full. | |
| 165 | + | select { | |
| 166 | + | case a.buf <- samples: | |
| 167 | + | return nil | |
| 168 | + | case <-a.closed: | |
| 169 | + | return ErrStreamClosed | |
| 170 | + | default: | |
| 171 | + | // Buffer full — drop to avoid blocking the CEF audio thread. | |
| 172 | + | n := a.dropCount.Add(1) | |
| 173 | + | if n == 1 || n&(n-1) == 0 { // first drop, then powers of two | |
| 174 | + | if a.ctx != nil { | |
| 175 | + | logging.FromContext(a.ctx).Warn(). | |
| 176 | + | Uint64("total_drops", n). | |
| 177 | + | Msg("pipewire: dropped audio packet (buffer full)") | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // fill is the PlayerCallbacks.Fill function called by purego-pipewire | |
| 185 | + | // when it needs audio data. It pulls from the internal buffer channel. | |
| 186 | + | func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) { | |
| 187 | + | select { | |
| 188 | + | case samples := <-a.buf: | |
| 189 | + | fillNum := a.fillCount.Add(1) | |
| 190 | + | if fillNum == 1 && a.ctx != nil { | |
| 191 | + | logging.FromContext(a.ctx).Info(). | |
| 192 | + | Msg("pipewire: first Fill callback with data") | |
| 193 | + | } | |
| 194 | + | // Copy available samples into the PCM buffer | |
| 195 | + | copied := copyToPCM(pcm, samples) | |
| 196 | + | return copied, nil | |
| 197 | + | default: | |
| 198 | + | // No data available — return 0 frames, let underrun policy handle it | |
| 199 | + | underruns := a.underrunCount.Add(1) | |
| 200 | + | if underruns == 1 && a.ctx != nil { | |
| 201 | + | logging.FromContext(a.ctx).Debug(). | |
| 202 | + | Msg("pipewire: first Fill underrun (no data available)") | |
| 203 | + | } | |
| 204 | + | return 0, nil | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // copyToPCM copies planar samples into the PCMBuffer. | |
| 209 | + | // Returns the number of frames actually copied. | |
| 210 | + | func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32) int { | |
| 211 | + | if pcm == nil || len(samples) == 0 { | |
| 212 | + | return 0 | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | frames := pcm.Frames | |
| 216 | + | channels := len(samples) | |
| 217 | + | if channels > pcm.Channels { | |
| 218 | + | channels = pcm.Channels | |
| 219 | + | } | |
| 220 | + | if channels > len(pcm.Samples) { | |
| 221 | + | channels = len(pcm.Samples) | |
| 222 | + | } | |
| 223 | + | if channels == 0 { | |
| 224 | + | return 0 | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | minFrames := frames | |
| 228 | + | ||
| 229 | + | for ch := 0; ch < channels && ch < len(samples); ch++ { | |
| 230 | + | dst := pcm.Samples[ch] | |
| 231 | + | src := samples[ch] | |
| 232 | + | if len(dst) == 0 || len(src) == 0 { | |
| 233 | + | minFrames = 0 | |
| 234 | + | continue | |
| 235 | + | } | |
| 236 | + | srcFrames := len(src) | |
| 237 | + | n := frames | |
| 238 | + | if srcFrames < n { | |
| 239 | + | n = srcFrames | |
| 240 | + | } | |
| 241 | + | if len(dst) < n { | |
| 242 | + | n = len(dst) | |
| 243 | + | } | |
| 244 | + | if n < minFrames { | |
| 245 | + | minFrames = n | |
| 246 | + | } | |
| 247 | + | copy(dst[:n], src[:n]) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | return minFrames | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // Close stops the player and releases resources. | |
| 254 | + | // Safe to call multiple times. | |
| 255 | + | func (a *playerStreamAdapter) Close() error { | |
| 256 | + | var err error | |
| 257 | + | a.closeOnce.Do(func() { | |
| 258 | + | if a.ctx != nil { | |
| 259 | + | logging.FromContext(a.ctx).Info(). | |
| 260 | + | Uint64("fills", a.fillCount.Load()). | |
| 261 | + | Uint64("underruns", a.underrunCount.Load()). | |
| 262 | + | Uint64("drops", a.dropCount.Load()). | |
| 263 | + | Msg("pipewire: closing player") | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Signal writers to stop | |
| 267 | + | close(a.closed) | |
| 268 | + | ||
| 269 | + | if a.player != nil { | |
| 270 | + | // Stop playback then close the player | |
| 271 | + | if stopErr := a.player.Stop(); stopErr != nil { | |
| 272 | + | err = stopErr | |
| 273 | + | } | |
| 274 | + | if closeErr := a.player.Close(); closeErr != nil && err == nil { | |
| 275 | + | err = closeErr | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | }) | |
| 279 | + | return err | |
| 280 | + | } | |
A internal/infrastructure/audio/pipewire/output_test.go +548 -0
| @@ -0,0 +1,548 @@ | |||
| 1 | + | // Package pipewire provides a PipeWire-based audio output implementation. | |
| 2 | + | // This wraps purego-pipewire's high-level Player API. | |
| 3 | + | package pipewire | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | pwpipewire "github.com/bnema/purego-pipewire/pipewire" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // --- Test doubles --- | |
| 17 | + | ||
| 18 | + | // fakePlayer is a test double for purego-pipewire Player. | |
| 19 | + | type fakePlayer struct { | |
| 20 | + | started bool | |
| 21 | + | paused bool | |
| 22 | + | stopped bool | |
| 23 | + | closedFlag bool | |
| 24 | + | state pwpipewire.PlayerState | |
| 25 | + | ||
| 26 | + | startErr error | |
| 27 | + | stopErr error | |
| 28 | + | closeErr error | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (p *fakePlayer) Start() error { | |
| 32 | + | if p.startErr != nil { | |
| 33 | + | return p.startErr | |
| 34 | + | } | |
| 35 | + | p.started = true | |
| 36 | + | p.state = pwpipewire.PlayerStatePlaying | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (p *fakePlayer) Pause() error { | |
| 41 | + | p.paused = true | |
| 42 | + | p.state = pwpipewire.PlayerStatePaused | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (p *fakePlayer) Stop() error { | |
| 47 | + | if p.stopErr != nil { | |
| 48 | + | return p.stopErr | |
| 49 | + | } | |
| 50 | + | p.stopped = true | |
| 51 | + | p.state = pwpipewire.PlayerStateStopped | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (p *fakePlayer) Close() error { | |
| 56 | + | if p.closeErr != nil { | |
| 57 | + | return p.closeErr | |
| 58 | + | } | |
| 59 | + | p.closedFlag = true | |
| 60 | + | p.state = pwpipewire.PlayerStateClosed | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (p *fakePlayer) State() pwpipewire.PlayerState { | |
| 65 | + | return p.state | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // --- Factory tests --- | |
| 69 | + | ||
| 70 | + | func TestFactory_NewStream_ValidFormat_CreatesStream(t *testing.T) { | |
| 71 | + | var capturedConfig pwpipewire.PlayerConfig | |
| 72 | + | player := &fakePlayer{} | |
| 73 | + | ||
| 74 | + | factory := &Factory{ | |
| 75 | + | createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 76 | + | capturedConfig = config | |
| 77 | + | return player, nil | |
| 78 | + | }, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | format := port.AudioStreamFormat{ | |
| 82 | + | SampleRate: 48000, | |
| 83 | + | ChannelCount: 2, | |
| 84 | + | FramesPerBuffer: 512, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | stream, err := factory.NewStream(context.Background(), format) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | require.NotNil(t, stream) | |
| 90 | + | ||
| 91 | + | // Verify config mapping | |
| 92 | + | assert.Equal(t, 48000, capturedConfig.SampleRate) | |
| 93 | + | assert.Equal(t, 2, capturedConfig.Channels) | |
| 94 | + | assert.Equal(t, 512, capturedConfig.FramesPerBuffer) | |
| 95 | + | assert.Equal(t, pwpipewire.SampleFormatF32, capturedConfig.SampleFormat) | |
| 96 | + | assert.Equal(t, pwpipewire.UnderrunFillSilence, capturedConfig.UnderrunPolicy) | |
| 97 | + | ||
| 98 | + | // Verify player was started | |
| 99 | + | assert.True(t, player.started) | |
| 100 | + | ||
| 101 | + | // Clean up | |
| 102 | + | require.NoError(t, stream.Close()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestFactory_NewStream_InvalidFormat_ReturnsError(t *testing.T) { | |
| 106 | + | factory := &Factory{ | |
| 107 | + | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 108 | + | t.Fatal("createPlayer should not be called for invalid format") | |
| 109 | + | return nil, nil | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | tests := []struct { | |
| 114 | + | name string | |
| 115 | + | format port.AudioStreamFormat | |
| 116 | + | }{ | |
| 117 | + | { | |
| 118 | + | name: "zero sample rate", | |
| 119 | + | format: port.AudioStreamFormat{SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512}, | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | name: "zero channels", | |
| 123 | + | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512}, | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "negative sample rate", | |
| 127 | + | format: port.AudioStreamFormat{SampleRate: -48000, ChannelCount: 2, FramesPerBuffer: 512}, | |
| 128 | + | }, | |
| 129 | + | { | |
| 130 | + | name: "negative channels", | |
| 131 | + | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: -2, FramesPerBuffer: 512}, | |
| 132 | + | }, | |
| 133 | + | { | |
| 134 | + | name: "excessive sample rate", | |
| 135 | + | format: port.AudioStreamFormat{SampleRate: 1000000, ChannelCount: 2, FramesPerBuffer: 512}, | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "excessive channels", | |
| 139 | + | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 1025, FramesPerBuffer: 512}, | |
| 140 | + | }, | |
| 141 | + | { | |
| 142 | + | name: "zero frames per buffer", | |
| 143 | + | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0}, | |
| 144 | + | }, | |
| 145 | + | { | |
| 146 | + | name: "excessive frames per buffer", | |
| 147 | + | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 10000}, | |
| 148 | + | }, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | for _, tt := range tests { | |
| 152 | + | t.Run(tt.name, func(t *testing.T) { | |
| 153 | + | stream, err := factory.NewStream(context.Background(), tt.format) | |
| 154 | + | require.Error(t, err) | |
| 155 | + | assert.Nil(t, stream) | |
| 156 | + | assert.ErrorIs(t, err, ErrInvalidFormat) | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestFactory_NewStream_PlayerCreationFails_ReturnsError(t *testing.T) { | |
| 162 | + | expectedErr := errors.New("pipewire not available") | |
| 163 | + | factory := &Factory{ | |
| 164 | + | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 165 | + | return nil, expectedErr | |
| 166 | + | }, | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512} | |
| 170 | + | stream, err := factory.NewStream(context.Background(), format) | |
| 171 | + | ||
| 172 | + | require.Error(t, err) | |
| 173 | + | assert.Nil(t, stream) | |
| 174 | + | assert.ErrorIs(t, err, expectedErr) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestFactory_NewStream_PlayerStartFails_ClosesAndReturnsError(t *testing.T) { | |
| 178 | + | player := &fakePlayer{startErr: errors.New("start failed")} | |
| 179 | + | factory := &Factory{ | |
| 180 | + | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 181 | + | return player, nil | |
| 182 | + | }, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512} | |
| 186 | + | stream, err := factory.NewStream(context.Background(), format) | |
| 187 | + | ||
| 188 | + | require.Error(t, err) | |
| 189 | + | assert.Nil(t, stream) | |
| 190 | + | assert.True(t, player.closedFlag, "player should be closed after start failure") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestFactory_NewStream_ContextCancelled_ReturnsError(t *testing.T) { | |
| 194 | + | factory := &Factory{ | |
| 195 | + | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 196 | + | t.Fatal("createPlayer should not be called when context is canceled") | |
| 197 | + | return nil, nil | |
| 198 | + | }, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 202 | + | cancel() | |
| 203 | + | ||
| 204 | + | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512} | |
| 205 | + | stream, err := factory.NewStream(ctx, format) | |
| 206 | + | ||
| 207 | + | require.Error(t, err) | |
| 208 | + | assert.Nil(t, stream) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestFactory_NewStream_MultipleFormats_Accepted(t *testing.T) { | |
| 212 | + | formats := []port.AudioStreamFormat{ | |
| 213 | + | {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}, | |
| 214 | + | {SampleRate: 44100, ChannelCount: 2, FramesPerBuffer: 512}, | |
| 215 | + | {SampleRate: 48000, ChannelCount: 1, FramesPerBuffer: 256}, | |
| 216 | + | {SampleRate: 96000, ChannelCount: 2, FramesPerBuffer: 1024}, | |
| 217 | + | {SampleRate: 48000, ChannelCount: 6, FramesPerBuffer: 512}, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | for _, fmt := range formats { | |
| 221 | + | t.Run("", func(t *testing.T) { | |
| 222 | + | factory := &Factory{ | |
| 223 | + | createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 224 | + | assert.Equal(t, fmt.SampleRate, config.SampleRate) | |
| 225 | + | assert.Equal(t, fmt.ChannelCount, config.Channels) | |
| 226 | + | assert.Equal(t, fmt.FramesPerBuffer, config.FramesPerBuffer) | |
| 227 | + | return &fakePlayer{}, nil | |
| 228 | + | }, | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | stream, err := factory.NewStream(context.Background(), fmt) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | assert.NotNil(t, stream) | |
| 234 | + | ||
| 235 | + | _ = stream.Close() | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestFactory_ImplementsPortInterface(_ *testing.T) { | |
| 241 | + | var _ port.AudioOutputFactory = (*Factory)(nil) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // --- Stream adapter tests --- | |
| 245 | + | ||
| 246 | + | func TestPlayerStreamAdapter_Write_SendsToBuffer(t *testing.T) { | |
| 247 | + | player := &fakePlayer{} | |
| 248 | + | adapter := &playerStreamAdapter{ | |
| 249 | + | player: player, | |
| 250 | + | buf: make(chan [][]float32, 4), | |
| 251 | + | closed: make(chan struct{}), | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | samples := [][]float32{ | |
| 255 | + | {0.1, 0.2}, | |
| 256 | + | {0.3, 0.4}, | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | err := adapter.Write(samples) | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | ||
| 262 | + | // Verify data is in the channel | |
| 263 | + | received := <-adapter.buf | |
| 264 | + | assert.Equal(t, samples, received) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestPlayerStreamAdapter_Write_AfterClose_ReturnsError(t *testing.T) { | |
| 268 | + | player := &fakePlayer{} | |
| 269 | + | adapter := &playerStreamAdapter{ | |
| 270 | + | player: player, | |
| 271 | + | buf: make(chan [][]float32, 4), | |
| 272 | + | closed: make(chan struct{}), | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | require.NoError(t, adapter.Close()) | |
| 276 | + | ||
| 277 | + | err := adapter.Write([][]float32{{0.1}}) | |
| 278 | + | assert.ErrorIs(t, err, ErrStreamClosed) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func TestPlayerStreamAdapter_Close_StopsAndClosesPlayer(t *testing.T) { | |
| 282 | + | player := &fakePlayer{} | |
| 283 | + | adapter := &playerStreamAdapter{ | |
| 284 | + | player: player, | |
| 285 | + | buf: make(chan [][]float32, 4), | |
| 286 | + | closed: make(chan struct{}), | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | err := adapter.Close() | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | ||
| 292 | + | assert.True(t, player.stopped) | |
| 293 | + | assert.True(t, player.closedFlag) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestPlayerStreamAdapter_Close_IsIdempotent(t *testing.T) { | |
| 297 | + | player := &fakePlayer{} | |
| 298 | + | adapter := &playerStreamAdapter{ | |
| 299 | + | player: player, | |
| 300 | + | buf: make(chan [][]float32, 4), | |
| 301 | + | closed: make(chan struct{}), | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | require.NoError(t, adapter.Close()) | |
| 305 | + | require.NoError(t, adapter.Close()) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestPlayerStreamAdapter_Close_PropagatesStopError(t *testing.T) { | |
| 309 | + | player := &fakePlayer{stopErr: errors.New("stop error")} | |
| 310 | + | adapter := &playerStreamAdapter{ | |
| 311 | + | player: player, | |
| 312 | + | buf: make(chan [][]float32, 4), | |
| 313 | + | closed: make(chan struct{}), | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | err := adapter.Close() | |
| 317 | + | require.Error(t, err) | |
| 318 | + | assert.Contains(t, err.Error(), "stop error") | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestPlayerStreamAdapter_Close_PropagatesCloseError(t *testing.T) { | |
| 322 | + | player := &fakePlayer{closeErr: errors.New("close error")} | |
| 323 | + | adapter := &playerStreamAdapter{ | |
| 324 | + | player: player, | |
| 325 | + | buf: make(chan [][]float32, 4), | |
| 326 | + | closed: make(chan struct{}), | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | err := adapter.Close() | |
| 330 | + | require.Error(t, err) | |
| 331 | + | assert.Contains(t, err.Error(), "close error") | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // --- Fill callback tests --- | |
| 335 | + | ||
| 336 | + | func TestFill_PullsFromBuffer(t *testing.T) { | |
| 337 | + | adapter := &playerStreamAdapter{ | |
| 338 | + | buf: make(chan [][]float32, 4), | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // Push samples | |
| 342 | + | samples := [][]float32{ | |
| 343 | + | {0.1, 0.2, 0.3}, | |
| 344 | + | {0.4, 0.5, 0.6}, | |
| 345 | + | } | |
| 346 | + | adapter.buf <- samples | |
| 347 | + | ||
| 348 | + | // Simulate Fill call | |
| 349 | + | pcm := &pwpipewire.PCMBuffer{ | |
| 350 | + | Frames: 3, | |
| 351 | + | Channels: 2, | |
| 352 | + | Stride: 1, | |
| 353 | + | Samples: [][]float32{ | |
| 354 | + | make([]float32, 3), | |
| 355 | + | make([]float32, 3), | |
| 356 | + | }, | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | frames, err := adapter.fill(pcm) | |
| 360 | + | require.NoError(t, err) | |
| 361 | + | assert.Equal(t, 3, frames) | |
| 362 | + | assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001) | |
| 363 | + | assert.InEpsilon(t, 0.4, pcm.Samples[1][0], 0.0001) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestFill_ReturnsZeroWhenEmpty(t *testing.T) { | |
| 367 | + | adapter := &playerStreamAdapter{ | |
| 368 | + | buf: make(chan [][]float32, 4), | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | pcm := &pwpipewire.PCMBuffer{ | |
| 372 | + | Frames: 256, | |
| 373 | + | Channels: 2, | |
| 374 | + | Stride: 1, | |
| 375 | + | Samples: [][]float32{ | |
| 376 | + | make([]float32, 256), | |
| 377 | + | make([]float32, 256), | |
| 378 | + | }, | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | frames, err := adapter.fill(pcm) | |
| 382 | + | require.NoError(t, err) | |
| 383 | + | assert.Equal(t, 0, frames) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func TestCopyToPCM_HandlesChannelMismatch(t *testing.T) { | |
| 387 | + | // More source channels than PCM channels — truncates | |
| 388 | + | pcm := &pwpipewire.PCMBuffer{ | |
| 389 | + | Frames: 2, | |
| 390 | + | Channels: 1, | |
| 391 | + | Samples: [][]float32{make([]float32, 2)}, | |
| 392 | + | } | |
| 393 | + | samples := [][]float32{ | |
| 394 | + | {1.0, 2.0}, | |
| 395 | + | {3.0, 4.0}, // should be ignored | |
| 396 | + | } | |
| 397 | + | frames := copyToPCM(pcm, samples) | |
| 398 | + | assert.Equal(t, 2, frames) | |
| 399 | + | assert.InEpsilon(t, 1.0, pcm.Samples[0][0], 0.0001) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func TestCopyToPCM_HandlesFrameMismatch(t *testing.T) { | |
| 403 | + | // Fewer source frames than PCM expects | |
| 404 | + | pcm := &pwpipewire.PCMBuffer{ | |
| 405 | + | Frames: 4, | |
| 406 | + | Channels: 1, | |
| 407 | + | Samples: [][]float32{make([]float32, 4)}, | |
| 408 | + | } | |
| 409 | + | samples := [][]float32{ | |
| 410 | + | {1.0, 2.0}, // only 2 frames | |
| 411 | + | } | |
| 412 | + | frames := copyToPCM(pcm, samples) | |
| 413 | + | assert.Equal(t, 2, frames) // reports partial fill | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestCopyToPCM_NilPCM_ReturnsZero(t *testing.T) { | |
| 417 | + | assert.Equal(t, 0, copyToPCM(nil, [][]float32{{1.0}})) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestCopyToPCM_EmptySamples_ReturnsZero(t *testing.T) { | |
| 421 | + | pcm := &pwpipewire.PCMBuffer{Frames: 1, Channels: 1, Samples: [][]float32{make([]float32, 1)}} | |
| 422 | + | assert.Equal(t, 0, copyToPCM(pcm, nil)) | |
| 423 | + | assert.Equal(t, 0, copyToPCM(pcm, [][]float32{})) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // --- Non-blocking Write tests --- | |
| 427 | + | ||
| 428 | + | func TestPlayerStreamAdapter_Write_DropsWhenBufferFull(t *testing.T) { | |
| 429 | + | adapter := &playerStreamAdapter{ | |
| 430 | + | player: &fakePlayer{}, | |
| 431 | + | buf: make(chan [][]float32, 4), | |
| 432 | + | closed: make(chan struct{}), | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // Fill the buffer to capacity | |
| 436 | + | for i := 0; i < 4; i++ { | |
| 437 | + | require.NoError(t, adapter.Write([][]float32{{float32(i)}})) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // The 5th write must NOT block — it should drop the packet and return nil. | |
| 441 | + | err := adapter.Write([][]float32{{99.0}}) | |
| 442 | + | require.NoError(t, err, "Write must not return an error when dropping due to full buffer") | |
| 443 | + | ||
| 444 | + | // Verify the drop was counted | |
| 445 | + | assert.Equal(t, uint64(1), adapter.dropCount.Load(), "one packet should have been dropped") | |
| 446 | + | ||
| 447 | + | // Verify buffer still contains the original 4 packets (not the dropped one) | |
| 448 | + | assert.Len(t, adapter.buf, 4) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func TestPlayerStreamAdapter_Write_DropsLogOnlyOnFirstAndPowerOfTwo(t *testing.T) { | |
| 452 | + | adapter := &playerStreamAdapter{ | |
| 453 | + | player: &fakePlayer{}, | |
| 454 | + | buf: make(chan [][]float32, 1), // tiny buffer to force drops | |
| 455 | + | closed: make(chan struct{}), | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // Fill buffer | |
| 459 | + | require.NoError(t, adapter.Write([][]float32{{0.0}})) | |
| 460 | + | ||
| 461 | + | // Drop 10 packets | |
| 462 | + | for i := 0; i < 10; i++ { | |
| 463 | + | _ = adapter.Write([][]float32{{float32(i)}}) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | // Verify all drops were counted | |
| 467 | + | assert.Equal(t, uint64(10), adapter.dropCount.Load()) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // --- Concurrent Write / Close tests --- | |
| 471 | + | ||
| 472 | + | func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) { | |
| 473 | + | player := &fakePlayer{} | |
| 474 | + | adapter := &playerStreamAdapter{ | |
| 475 | + | player: player, | |
| 476 | + | buf: make(chan [][]float32, 4), | |
| 477 | + | closed: make(chan struct{}), | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | done := make(chan struct{}) | |
| 481 | + | go func() { | |
| 482 | + | defer close(done) | |
| 483 | + | for i := 0; i < 100; i++ { | |
| 484 | + | err := adapter.Write([][]float32{{float32(i)}}) | |
| 485 | + | if err != nil { | |
| 486 | + | assert.ErrorIs(t, err, ErrStreamClosed) | |
| 487 | + | return | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | }() | |
| 491 | + | ||
| 492 | + | // Let a few writes land, then close. | |
| 493 | + | adapter.Close() | |
| 494 | + | <-done | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // --- NewFactory tests --- | |
| 498 | + | ||
| 499 | + | func TestNewFactory_Succeeds(t *testing.T) { | |
| 500 | + | factory, err := NewFactory() | |
| 501 | + | require.NoError(t, err) | |
| 502 | + | require.NotNil(t, factory) | |
| 503 | + | assert.NotNil(t, factory.createPlayer) | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | // --- Integration-style test: full write-through --- | |
| 507 | + | ||
| 508 | + | func TestStream_WriteFlowsThroughToFill(t *testing.T) { | |
| 509 | + | var capturedCallbacks pwpipewire.PlayerCallbacks | |
| 510 | + | player := &fakePlayer{} | |
| 511 | + | ||
| 512 | + | factory := &Factory{ | |
| 513 | + | createPlayer: func(_ pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 514 | + | capturedCallbacks = callbacks | |
| 515 | + | return player, nil | |
| 516 | + | }, | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4} | |
| 520 | + | stream, err := factory.NewStream(context.Background(), format) | |
| 521 | + | require.NoError(t, err) | |
| 522 | + | ||
| 523 | + | // Write samples through the stream | |
| 524 | + | samples := [][]float32{ | |
| 525 | + | {0.1, 0.2, 0.3, 0.4}, | |
| 526 | + | {0.5, 0.6, 0.7, 0.8}, | |
| 527 | + | } | |
| 528 | + | require.NoError(t, stream.Write(samples)) | |
| 529 | + | ||
| 530 | + | // Simulate the Fill callback being called by the player | |
| 531 | + | pcm := &pwpipewire.PCMBuffer{ | |
| 532 | + | Frames: 4, | |
| 533 | + | Channels: 2, | |
| 534 | + | Stride: 1, | |
| 535 | + | Samples: [][]float32{ | |
| 536 | + | make([]float32, 4), | |
| 537 | + | make([]float32, 4), | |
| 538 | + | }, | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | frames, err := capturedCallbacks.Fill(pcm) | |
| 542 | + | require.NoError(t, err) | |
| 543 | + | assert.Equal(t, 4, frames) | |
| 544 | + | assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001) | |
| 545 | + | assert.InEpsilon(t, 0.5, pcm.Samples[1][0], 0.0001) | |
| 546 | + | ||
| 547 | + | require.NoError(t, stream.Close()) | |
| 548 | + | } | |
M internal/infrastructure/cef/app.go +2 -2
| @@ -9,11 +9,11 @@ import ( | |||
| 9 | 9 | const dumbSchemeName = "dumb" | |
| 10 | 10 | ||
| 11 | 11 | func dumbSchemeOptions() int32 { | |
| 12 | - | return int32(purecef.SchemeOptionsSchemeOptionStandard | | |
| 12 | + | return purecef.SchemeOptionsSchemeOptionStandard | | |
| 13 | 13 | purecef.SchemeOptionsSchemeOptionSecure | | |
| 14 | 14 | purecef.SchemeOptionsSchemeOptionCorsEnabled | | |
| 15 | 15 | purecef.SchemeOptionsSchemeOptionCspBypassing | | |
| 16 | - | purecef.SchemeOptionsSchemeOptionFetchEnabled) | |
| 16 | + | purecef.SchemeOptionsSchemeOptionFetchEnabled | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | func registerDumbScheme(registrar purecef.SchemeRegistrar) { | |
M internal/infrastructure/cef/app_test.go +2 -2
| @@ -25,11 +25,11 @@ func TestRegisterDumbScheme(t *testing.T) { | |||
| 25 | 25 | stub := &schemeRegistrarStub{} | |
| 26 | 26 | registerDumbScheme(stub) | |
| 27 | 27 | ||
| 28 | - | wantOptions := int32(purecef.SchemeOptionsSchemeOptionStandard | | |
| 28 | + | wantOptions := purecef.SchemeOptionsSchemeOptionStandard | | |
| 29 | 29 | purecef.SchemeOptionsSchemeOptionSecure | | |
| 30 | 30 | purecef.SchemeOptionsSchemeOptionCorsEnabled | | |
| 31 | 31 | purecef.SchemeOptionsSchemeOptionCspBypassing | | |
| 32 | - | purecef.SchemeOptionsSchemeOptionFetchEnabled) | |
| 32 | + | purecef.SchemeOptionsSchemeOptionFetchEnabled | |
| 33 | 33 | ||
| 34 | 34 | if stub.calls != 1 { | |
| 35 | 35 | t.Fatalf("AddCustomScheme call count = %d, want 1", stub.calls) | |
M internal/infrastructure/cef/content_injector.go +6 -2
| @@ -252,11 +252,15 @@ const redditDirectVideoJS = `(function(){ | |||
| 252 | 252 | function monitorReplacement(video) { | |
| 253 | 253 | if (!video || video._dumberReplacementDiag) return; | |
| 254 | 254 | video._dumberReplacementDiag = true; | |
| 255 | - | ['loadstart','loadedmetadata','loadeddata','canplay','playing','waiting','stalled','suspend','abort','emptied','error'].forEach(function(evt) { | |
| 255 | + | [ | |
| 256 | + | 'loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'playing', | |
| 257 | + | 'waiting', 'stalled', 'suspend', 'abort', 'emptied', 'error', | |
| 258 | + | ].forEach(function(evt) { | |
| 256 | 259 | video.addEventListener(evt, function() { | |
| 257 | 260 | var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none'; | |
| 258 | 261 | console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState, | |
| 259 | - | 'paused:', video.paused, 'error:', err, 'src:', (video.currentSrc || video.src || '').substring(0, 120)); | |
| 262 | + | 'paused:', video.paused, 'error:', err, 'src:', | |
| 263 | + | (video.currentSrc || video.src || '').substring(0, 120)); | |
| 260 | 264 | }); | |
| 261 | 265 | }); | |
| 262 | 266 | } | |
M internal/infrastructure/cef/engine.go +9 -48
| @@ -32,7 +32,7 @@ type Engine struct { | |||
| 32 | 32 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| 33 | 33 | activeCount atomic.Int32 | |
| 34 | 34 | ||
| 35 | - | // shutdownNotify is signalled when the active webview count reaches 0 | |
| 35 | + | // shutdownNotify is signaled when the active webview count reaches 0 | |
| 36 | 36 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| 37 | 37 | shutdownNotify chan struct{} | |
| 38 | 38 | ||
| @@ -42,14 +42,13 @@ type Engine struct { | |||
| 42 | 42 | childLaunchGPU atomic.Uint64 | |
| 43 | 43 | childLaunchOther atomic.Uint64 | |
| 44 | 44 | ||
| 45 | - | browserCreateRequests atomic.Uint64 | |
| 46 | - | browserAfterCreated atomic.Uint64 | |
| 47 | - | browserCreateLastAtMs atomic.Int64 | |
| 48 | - | browserCreateLastResult atomic.Int32 | |
| 49 | - | browserCreateLastWidth atomic.Int32 | |
| 50 | - | browserCreateLastHeight atomic.Int32 | |
| 51 | - | lastStallLoggedCreateSeq atomic.Uint64 | |
| 52 | - | browserCreateComplete atomic.Bool | |
| 45 | + | browserCreateRequests atomic.Uint64 | |
| 46 | + | browserAfterCreated atomic.Uint64 | |
| 47 | + | browserCreateLastAtMs atomic.Int64 | |
| 48 | + | browserCreateLastResult atomic.Int32 | |
| 49 | + | browserCreateLastWidth atomic.Int32 | |
| 50 | + | browserCreateLastHeight atomic.Int32 | |
| 51 | + | browserCreateComplete atomic.Bool | |
| 53 | 52 | ||
| 54 | 53 | transcoderState transcoderStartupState | |
| 55 | 54 | transcoderStateLogged atomic.Bool | |
| @@ -166,6 +165,7 @@ func (e *Engine) closeActiveWebViews() { | |||
| 166 | 165 | } | |
| 167 | 166 | ||
| 168 | 167 | log := logging.FromContext(e.ctx) | |
| 168 | + | e.shutdownNotify = make(chan struct{}, 1) | |
| 169 | 169 | webViews := make([]*WebView, 0, e.activeWebViewCount()) | |
| 170 | 170 | e.activeWebViews.Range(func(_, value any) bool { | |
| 171 | 171 | wv, ok := value.(*WebView) | |
| @@ -183,9 +183,6 @@ func (e *Engine) closeActiveWebViews() { | |||
| 183 | 183 | Int("count", len(webViews)). | |
| 184 | 184 | Msg("cef: closing active webviews before shutdown") | |
| 185 | 185 | ||
| 186 | - | // Set up the shutdown notification channel before destroying webviews. | |
| 187 | - | e.shutdownNotify = make(chan struct{}, 1) | |
| 188 | - | ||
| 189 | 186 | for _, wv := range webViews { | |
| 190 | 187 | wv.Destroy() | |
| 191 | 188 | } | |
| @@ -262,8 +259,6 @@ func (e *Engine) SetHandlerContext(ctx context.Context) { | |||
| 262 | 259 | e.logTranscoderStartupState() | |
| 263 | 260 | } | |
| 264 | 261 | ||
| 265 | - | const browserCreateStallWarnMS int64 = 1500 | |
| 266 | - | ||
| 267 | 262 | func (e *Engine) recordContextInitialized() { | |
| 268 | 263 | count := e.contextInitializedCount.Add(1) | |
| 269 | 264 | logging.FromContext(e.ctx).Debug(). | |
| @@ -331,40 +326,6 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) { | |||
| 331 | 326 | Msg("cef: browser created") | |
| 332 | 327 | } | |
| 333 | 328 | ||
| 334 | - | func (e *Engine) maybeLogBrowserCreateStall() { | |
| 335 | - | if e.browserCreateComplete.Load() { | |
| 336 | - | return | |
| 337 | - | } | |
| 338 | - | createCount := e.browserCreateRequests.Load() | |
| 339 | - | if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() { | |
| 340 | - | return | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | createdAt := e.browserCreateLastAtMs.Load() | |
| 344 | - | if createdAt == 0 { | |
| 345 | - | return | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | ageMs := time.Now().UnixMilli() - createdAt | |
| 349 | - | if ageMs < browserCreateStallWarnMS { | |
| 350 | - | return | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | e.lastStallLoggedCreateSeq.Store(createCount) | |
| 354 | - | logging.FromContext(e.ctx).Warn(). | |
| 355 | - | Uint64("create_requests", createCount). | |
| 356 | - | Uint64("after_created", e.browserAfterCreated.Load()). | |
| 357 | - | Int64("create_age_ms", ageMs). | |
| 358 | - | Int32("last_create_result", e.browserCreateLastResult.Load()). | |
| 359 | - | Int32("last_create_width", e.browserCreateLastWidth.Load()). | |
| 360 | - | Int32("last_create_height", e.browserCreateLastHeight.Load()). | |
| 361 | - | Uint64("context_initialized", e.contextInitializedCount.Load()). | |
| 362 | - | Uint64("renderer_launches", e.childLaunchRenderer.Load()). | |
| 363 | - | Uint64("gpu_launches", e.childLaunchGPU.Load()). | |
| 364 | - | Uint64("other_launches", e.childLaunchOther.Load()). | |
| 365 | - | Msg("cef: browser creation appears stalled") | |
| 366 | - | } | |
| 367 | - | ||
| 368 | 329 | func (e *Engine) currentContext() context.Context { | |
| 369 | 330 | if e == nil || e.ctx == nil { | |
| 370 | 331 | return context.Background() | |
M internal/infrastructure/cef/engine_init.go +9 -19
| @@ -24,7 +24,12 @@ import ( | |||
| 24 | 24 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 25 | 25 | ||
| 26 | 26 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 27 | - | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig) (*Engine, error) { | |
| 27 | + | func NewEngine( | |
| 28 | + | ctx context.Context, | |
| 29 | + | cfg config.CEFEngineConfig, | |
| 30 | + | transcodingCfg config.TranscodingConfig, | |
| 31 | + | audioFactory port.AudioOutputFactory, | |
| 32 | + | ) (*Engine, error) { | |
| 28 | 33 | logger := logging.FromContext(ctx) | |
| 29 | 34 | cleanStaleSingletonLocks(logger) | |
| 30 | 35 | ||
| @@ -55,7 +60,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c | |||
| 55 | 60 | } | |
| 56 | 61 | os.Args = savedArgs | |
| 57 | 62 | ||
| 58 | - | return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, logger) | |
| 63 | + | return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, audioFactory, logger) | |
| 59 | 64 | } | |
| 60 | 65 | ||
| 61 | 66 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| @@ -118,7 +123,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||
| 118 | 123 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | |
| 119 | 124 | func wireEngine( | |
| 120 | 125 | ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig, | |
| 121 | - | windowlessFrameRate int32, logger *zerolog.Logger, | |
| 126 | + | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 122 | 127 | ) (*Engine, error) { | |
| 123 | 128 | gl, err := newGLLoader() | |
| 124 | 129 | if err != nil { | |
| @@ -156,6 +161,7 @@ func wireEngine( | |||
| 156 | 161 | windowlessFrameRate: windowlessFrameRate, | |
| 157 | 162 | enableContextMenuHandler: cfg.EnableContextMenuHandler, | |
| 158 | 163 | transcoder: mediaTranscoder, | |
| 164 | + | audioOutputFactory: audioFactory, | |
| 159 | 165 | }) | |
| 160 | 166 | pool := newWebViewPool(factory) | |
| 161 | 167 | ||
| @@ -279,22 +285,6 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 { | |||
| 279 | 285 | return 1 | |
| 280 | 286 | } | |
| 281 | 287 | ||
| 282 | - | // detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate. | |
| 283 | - | // Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails. | |
| 284 | - | func detectMonitorRefreshRate(logger *zerolog.Logger) int32 { | |
| 285 | - | mon := getPrimaryMonitor() | |
| 286 | - | if mon == nil { | |
| 287 | - | return 0 | |
| 288 | - | } | |
| 289 | - | milliHz := mon.GetRefreshRate() | |
| 290 | - | if milliHz <= 0 { | |
| 291 | - | return 0 | |
| 292 | - | } | |
| 293 | - | hz := int32((milliHz + 500) / 1000) // round to nearest Hz | |
| 294 | - | logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate") | |
| 295 | - | return hz | |
| 296 | - | } | |
| 297 | - | ||
| 298 | 288 | // appendIfMissing appends flag to args if it's not already present. | |
| 299 | 289 | func appendIfMissing(args []string, flag string) []string { | |
| 300 | 290 | for _, a := range args { | |
M internal/infrastructure/cef/engine_init_test.go +10 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 10 | "github.com/rs/zerolog" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| @@ -64,3 +65,12 @@ func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) { | |||
| 64 | 65 | // In dev mode, should resolve under .dev/dumber/cef_user_data | |
| 65 | 66 | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | |
| 66 | 67 | } | |
| 68 | + | ||
| 69 | + | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | |
| 70 | + | // factory can be created and produces working streams. This tests the runtime | |
| 71 | + | // wiring decision to create the factory during CEF engine setup. | |
| 72 | + | func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |
| 73 | + | audioFactory := factory.NewAudioOutputFactory() | |
| 74 | + | ||
| 75 | + | require.NotNil(t, audioFactory, "Audio factory should not be nil") | |
| 76 | + | } | |
M internal/infrastructure/cef/factory.go +19 -9
| @@ -25,6 +25,7 @@ type WebViewFactory struct { | |||
| 25 | 25 | enableContextMenuHandler bool | |
| 26 | 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 27 | 27 | transcoder port.MediaTranscoder | |
| 28 | + | audioOutputFactory port.AudioOutputFactory | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | type webViewFactoryOptions struct { | |
| @@ -32,6 +33,7 @@ type webViewFactoryOptions struct { | |||
| 32 | 33 | windowlessFrameRate int32 | |
| 33 | 34 | enableContextMenuHandler bool | |
| 34 | 35 | transcoder port.MediaTranscoder | |
| 36 | + | audioOutputFactory port.AudioOutputFactory | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | // newWebViewFactory returns a factory that will create WebViews using the | |
| @@ -50,6 +52,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 50 | 52 | windowlessFrameRate: opts.windowlessFrameRate, | |
| 51 | 53 | enableContextMenuHandler: opts.enableContextMenuHandler, | |
| 52 | 54 | transcoder: opts.transcoder, | |
| 55 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 53 | 56 | } | |
| 54 | 57 | } | |
| 55 | 58 | ||
| @@ -69,10 +72,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 69 | 72 | pipeline := newRenderPipeline(ctx, f.gl, f.scale) | |
| 70 | 73 | ||
| 71 | 74 | wv := &WebView{ | |
| 72 | - | id: id, | |
| 73 | - | ctx: ctx, | |
| 74 | - | engine: f.engine, | |
| 75 | - | pipeline: pipeline, | |
| 75 | + | id: id, | |
| 76 | + | ctx: ctx, | |
| 77 | + | engine: f.engine, | |
| 78 | + | pipeline: pipeline, | |
| 79 | + | audioOutputFactory: f.audioOutputFactory, | |
| 76 | 80 | } | |
| 77 | 81 | ||
| 78 | 82 | var transcodingHandler purecef.ResourceRequestHandler | |
| @@ -135,13 +139,21 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 135 | 139 | // Defer browser creation until the GL area has a non-zero size. | |
| 136 | 140 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | |
| 137 | 141 | // is not yet realized at this point. | |
| 142 | + | f.configureInitialBrowserCreation(ctx, wv, pipeline, client, &windowInfo, &settings) | |
| 143 | + | ||
| 144 | + | return wv, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (f *WebViewFactory) configureInitialBrowserCreation( | |
| 148 | + | ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.Client, | |
| 149 | + | windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings, | |
| 150 | + | ) { | |
| 138 | 151 | wv.pendingCreate = &pendingBrowserCreate{ | |
| 139 | - | windowInfo: &windowInfo, | |
| 152 | + | windowInfo: windowInfo, | |
| 140 | 153 | client: client, | |
| 141 | - | settings: &settings, | |
| 154 | + | settings: settings, | |
| 142 | 155 | } | |
| 143 | 156 | ||
| 144 | - | // When the GL area gets its first non-zero size, create the browser. | |
| 145 | 157 | pipeline.onFirstResize = func(w, h int32) { | |
| 146 | 158 | log := logging.FromContext(ctx) | |
| 147 | 159 | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| @@ -198,8 +210,6 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 198 | 210 | host.WasResized() | |
| 199 | 211 | } | |
| 200 | 212 | } | |
| 201 | - | ||
| 202 | - | return wv, nil | |
| 203 | 213 | } | |
| 204 | 214 | ||
| 205 | 215 | // createRelatedWarned ensures the session sharing warning is logged only once. | |
M internal/infrastructure/cef/render_pipeline.go +89 -91
| @@ -292,22 +292,12 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 292 | 292 | ||
| 293 | 293 | rp.mu.Unlock() | |
| 294 | 294 | ||
| 295 | - | gl := rp.gl | |
| 296 | - | ||
| 297 | 295 | if !rp.glReady || sizeChanged { | |
| 298 | 296 | rp.initGL(uploadWidth, uploadHeight) | |
| 299 | 297 | } | |
| 300 | 298 | ||
| 301 | 299 | if !rp.glReady { | |
| 302 | - | renderDuration := time.Since(renderStartedAt) | |
| 303 | - | rp.diagMu.Lock() | |
| 304 | - | rp.glRenderCount++ | |
| 305 | - | rp.renderTotalNs += uint64(renderDuration) | |
| 306 | - | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |
| 307 | - | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 308 | - | } | |
| 309 | - | rp.diagMu.Unlock() | |
| 310 | - | rp.maybeLogDiagnostics() | |
| 300 | + | rp.recordGLRenderDuration(renderStartedAt) | |
| 311 | 301 | return true | |
| 312 | 302 | } | |
| 313 | 303 | ||
| @@ -315,22 +305,12 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 315 | 305 | uploadStartedAt := time.Now() | |
| 316 | 306 | uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq) | |
| 317 | 307 | if didUpload { | |
| 318 | - | uploadDuration := time.Since(uploadStartedAt) | |
| 319 | - | rp.diagMu.Lock() | |
| 320 | - | rp.uploadCount++ | |
| 321 | - | if fullUpload { | |
| 322 | - | rp.fullUploadCount++ | |
| 323 | - | } | |
| 324 | - | rp.uploadBytes += uploadedBytes | |
| 325 | - | rp.uploadTotalNs += uint64(uploadDuration) | |
| 326 | - | if uploadDuration.Nanoseconds() > rp.maxUploadNs { | |
| 327 | - | rp.maxUploadNs = uploadDuration.Nanoseconds() | |
| 328 | - | } | |
| 329 | - | rp.diagMu.Unlock() | |
| 308 | + | rp.recordGLUploadDuration(uploadStartedAt, uploadedBytes, fullUpload) | |
| 330 | 309 | } | |
| 331 | 310 | } | |
| 332 | 311 | ||
| 333 | 312 | // Draw the main view texture. | |
| 313 | + | gl := rp.gl | |
| 334 | 314 | w, h, _ := rp.viewRectSize() | |
| 335 | 315 | gl.viewport(0, 0, w, h) | |
| 336 | 316 | gl.clearColor(0, 0, 0, 1) | |
| @@ -342,13 +322,7 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 342 | 322 | } | |
| 343 | 323 | ||
| 344 | 324 | renderDuration := time.Since(renderStartedAt) | |
| 345 | - | rp.diagMu.Lock() | |
| 346 | - | rp.glRenderCount++ | |
| 347 | - | rp.renderTotalNs += uint64(renderDuration) | |
| 348 | - | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |
| 349 | - | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 350 | - | } | |
| 351 | - | rp.diagMu.Unlock() | |
| 325 | + | rp.recordGLRenderDuration(renderStartedAt) | |
| 352 | 326 | rp.maybeLogDiagnostics() | |
| 353 | 327 | if rp.ctx != nil { | |
| 354 | 328 | logging.FromContext(rp.ctx).Trace(). | |
| @@ -363,6 +337,33 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 363 | 337 | return true | |
| 364 | 338 | } | |
| 365 | 339 | ||
| 340 | + | func (rp *renderPipeline) recordGLRenderDuration(startedAt time.Time) { | |
| 341 | + | renderDuration := time.Since(startedAt) | |
| 342 | + | rp.diagMu.Lock() | |
| 343 | + | rp.glRenderCount++ | |
| 344 | + | rp.renderTotalNs += uint64(renderDuration) | |
| 345 | + | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |
| 346 | + | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 347 | + | } | |
| 348 | + | rp.diagMu.Unlock() | |
| 349 | + | rp.maybeLogDiagnostics() | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func (rp *renderPipeline) recordGLUploadDuration(startedAt time.Time, uploadedBytes uint64, fullUpload bool) { | |
| 353 | + | uploadDuration := time.Since(startedAt) | |
| 354 | + | rp.diagMu.Lock() | |
| 355 | + | rp.uploadCount++ | |
| 356 | + | if fullUpload { | |
| 357 | + | rp.fullUploadCount++ | |
| 358 | + | } | |
| 359 | + | rp.uploadBytes += uploadedBytes | |
| 360 | + | rp.uploadTotalNs += uint64(uploadDuration) | |
| 361 | + | if uploadDuration.Nanoseconds() > rp.maxUploadNs { | |
| 362 | + | rp.maxUploadNs = uploadDuration.Nanoseconds() | |
| 363 | + | } | |
| 364 | + | rp.diagMu.Unlock() | |
| 365 | + | } | |
| 366 | + | ||
| 366 | 367 | func (rp *renderPipeline) drawTexture(texture uint32) { | |
| 367 | 368 | if texture == 0 { | |
| 368 | 369 | return | |
| @@ -404,63 +405,76 @@ func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, | |||
| 404 | 405 | ||
| 405 | 406 | bufSize := int64(w) * int64(h) * 4 | |
| 406 | 407 | stride := int(w) * 4 | |
| 408 | + | uploadedBytes, fullUpload, dirtyRectCount, ok := rp.uploadMappedPBO(gl, bufSize, stride, w, h, renderSeq, paintSeq) | |
| 409 | + | if !ok { | |
| 410 | + | return 0, fullUpload, false | |
| 411 | + | } | |
| 407 | 412 | ||
| 408 | - | // Bind the write PBO. | |
| 409 | - | gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[rp.pboIndex]) | |
| 413 | + | // Swap PBO index for next frame. | |
| 414 | + | rp.pboIndex = 1 - rp.pboIndex | |
| 410 | 415 | ||
| 411 | - | // Orphan the buffer to avoid GPU stall, then map. | |
| 416 | + | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 417 | + | if rp.ctx != nil { | |
| 418 | + | logging.FromContext(rp.ctx).Trace(). | |
| 419 | + | Uint64("render_seq", renderSeq). | |
| 420 | + | Uint64("paint_seq", paintSeq). | |
| 421 | + | Uint64("uploaded_bytes", uploadedBytes). | |
| 422 | + | Int("dirty_rect_count", dirtyRectCount). | |
| 423 | + | Bool("full_upload", fullUpload). | |
| 424 | + | Int("next_pbo_index", rp.pboIndex). | |
| 425 | + | Msg("cef: uploadToPBO end") | |
| 426 | + | } | |
| 427 | + | return uploadedBytes, fullUpload, true | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (rp *renderPipeline) uploadMappedPBO( | |
| 431 | + | gl *glLoader, bufSize int64, stride int, w, h int32, renderSeq, paintSeq uint64, | |
| 432 | + | ) (uint64, bool, int, bool) { | |
| 433 | + | gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[rp.pboIndex]) | |
| 412 | 434 | gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw) | |
| 413 | 435 | mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly) | |
| 414 | 436 | if mapped == nil { | |
| 415 | 437 | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 416 | - | return 0, false, false | |
| 438 | + | return 0, false, 0, false | |
| 417 | 439 | } | |
| 418 | 440 | ||
| 419 | 441 | mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize)) | |
| 420 | - | uploadedBytes := uint64(0) | |
| 421 | - | fullUpload := false | |
| 422 | - | dirtyRects := make([]rect, 0) | |
| 442 | + | defer gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 423 | 443 | ||
| 424 | 444 | rp.mu.Lock() | |
| 425 | 445 | if !rp.needsUpload { | |
| 426 | 446 | rp.mu.Unlock() | |
| 427 | 447 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| 428 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 429 | - | return 0, false, false | |
| 448 | + | return 0, false, 0, false | |
| 430 | 449 | } | |
| 431 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 432 | - | return 0, false, false | |
| 450 | + | return 0, false, 0, false | |
| 433 | 451 | } | |
| 434 | 452 | ||
| 435 | 453 | if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight { | |
| 436 | 454 | rp.mu.Unlock() | |
| 437 | 455 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| 438 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 439 | - | return 0, false, false | |
| 456 | + | return 0, false, 0, false | |
| 440 | 457 | } | |
| 441 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 442 | 458 | if rp.ctx != nil { | |
| 443 | 459 | logging.FromContext(rp.ctx).Trace(). | |
| 444 | 460 | Uint64("render_seq", renderSeq). | |
| 445 | 461 | Uint64("paint_seq", paintSeq). | |
| 446 | 462 | Msg("cef: skipping upload while newer surface resize is pending") | |
| 447 | 463 | } | |
| 448 | - | return 0, false, false | |
| 464 | + | return 0, false, 0, false | |
| 449 | 465 | } | |
| 450 | 466 | ||
| 451 | - | fullUpload = rp.forceFullUpload | |
| 467 | + | fullUpload := rp.forceFullUpload | |
| 468 | + | dirtyRectCount, dirtyRects := len(rp.dirtyRects), make([]rect, 0, len(rp.dirtyRects)) | |
| 452 | 469 | if fullUpload { | |
| 453 | 470 | copied := copy(mappedSlice, rp.staging) | |
| 454 | 471 | if copied < len(mappedSlice) { | |
| 455 | 472 | clear(mappedSlice[copied:]) | |
| 456 | 473 | } | |
| 457 | - | uploadedBytes = uint64(copied) | |
| 458 | 474 | } else { | |
| 459 | 475 | dirtyRects = append(dirtyRects, rp.dirtyRects...) | |
| 460 | - | uploadedBytes = copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects) | |
| 461 | 476 | } | |
| 462 | - | rp.needsUpload = false | |
| 463 | - | rp.forceFullUpload = false | |
| 477 | + | rp.needsUpload, rp.forceFullUpload = false, false | |
| 464 | 478 | rp.dirtyRects = rp.dirtyRects[:0] | |
| 465 | 479 | rp.mu.Unlock() | |
| 466 | 480 | ||
| @@ -472,47 +486,30 @@ func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, | |||
| 472 | 486 | Bool("full_upload", fullUpload). | |
| 473 | 487 | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |
| 474 | 488 | } | |
| 475 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 476 | - | return 0, fullUpload, false | |
| 489 | + | return 0, fullUpload, dirtyRectCount, false | |
| 477 | 490 | } | |
| 478 | 491 | ||
| 479 | - | // Transfer PBO → texture. With PBO bound, the last arg is a byte offset. | |
| 480 | 492 | gl.bindTexture(glTexture2D, rp.texture) | |
| 481 | 493 | if fullUpload { | |
| 482 | 494 | gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil) | |
| 483 | - | } else { | |
| 484 | - | // Use GL_UNPACK_ROW_LENGTH so GL understands the PBO's full-width | |
| 485 | - | // stride, allowing one glTexSubImage2D per dirty rect instead of | |
| 486 | - | // one per row. | |
| 487 | - | gl.pixelStorei(glUnpackRowLength, w) | |
| 488 | - | for _, rawRect := range dirtyRects { | |
| 489 | - | r, ok := clampDirtyRect(rawRect, w, h) | |
| 490 | - | if !ok { | |
| 491 | - | continue | |
| 492 | - | } | |
| 493 | - | offset := uintptr(int(r.Y)*stride + int(r.X)*4) | |
| 494 | - | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, | |
| 495 | - | glBGRA, glUnsignedByte, pboOffset(offset)) | |
| 496 | - | } | |
| 497 | - | gl.pixelStorei(glUnpackRowLength, 0) | |
| 495 | + | gl.bindTexture(glTexture2D, 0) | |
| 496 | + | return uint64(len(mappedSlice)), true, dirtyRectCount, true | |
| 498 | 497 | } | |
| 499 | - | gl.bindTexture(glTexture2D, 0) | |
| 500 | - | ||
| 501 | - | // Swap PBO index for next frame. | |
| 502 | - | rp.pboIndex = 1 - rp.pboIndex | |
| 503 | 498 | ||
| 504 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 505 | - | if rp.ctx != nil { | |
| 506 | - | logging.FromContext(rp.ctx).Trace(). | |
| 507 | - | Uint64("render_seq", renderSeq). | |
| 508 | - | Uint64("paint_seq", paintSeq). | |
| 509 | - | Uint64("uploaded_bytes", uploadedBytes). | |
| 510 | - | Int("dirty_rect_count", len(dirtyRects)). | |
| 511 | - | Bool("full_upload", fullUpload). | |
| 512 | - | Int("next_pbo_index", rp.pboIndex). | |
| 513 | - | Msg("cef: uploadToPBO end") | |
| 499 | + | gl.pixelStorei(glUnpackRowLength, w) | |
| 500 | + | uploadedBytes := copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects) | |
| 501 | + | for _, rawRect := range dirtyRects { | |
| 502 | + | r, ok := clampDirtyRect(rawRect, w, h) | |
| 503 | + | if !ok { | |
| 504 | + | continue | |
| 505 | + | } | |
| 506 | + | offset := uintptr(int(r.Y)*stride + int(r.X)*4) | |
| 507 | + | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, | |
| 508 | + | glBGRA, glUnsignedByte, pboOffset(offset)) | |
| 514 | 509 | } | |
| 515 | - | return uploadedBytes, fullUpload, true | |
| 510 | + | gl.pixelStorei(glUnpackRowLength, 0) | |
| 511 | + | gl.bindTexture(glTexture2D, 0) | |
| 512 | + | return uploadedBytes, false, dirtyRectCount, true | |
| 516 | 513 | } | |
| 517 | 514 | ||
| 518 | 515 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | |
| @@ -1042,15 +1039,16 @@ func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect { | |||
| 1042 | 1039 | for changed { | |
| 1043 | 1040 | changed = false | |
| 1044 | 1041 | for i := 0; i < len(merged); i++ { | |
| 1045 | - | for j := i + 1; j < len(merged); j++ { | |
| 1046 | - | if rectsTouch(merged[i], merged[j]) { | |
| 1047 | - | merged[i] = boundingBox(merged[i], merged[j]) | |
| 1048 | - | // Remove j by swapping with last element. | |
| 1049 | - | merged[j] = merged[len(merged)-1] | |
| 1050 | - | merged = merged[:len(merged)-1] | |
| 1051 | - | changed = true | |
| 1052 | - | j-- // re-check the element now at index j | |
| 1042 | + | for j := i + 1; j < len(merged); { | |
| 1043 | + | if !rectsTouch(merged[i], merged[j]) { | |
| 1044 | + | j++ | |
| 1045 | + | continue | |
| 1053 | 1046 | } | |
| 1047 | + | merged[i] = boundingBox(merged[i], merged[j]) | |
| 1048 | + | // Remove j by swapping with last element. | |
| 1049 | + | merged[j] = merged[len(merged)-1] | |
| 1050 | + | merged = merged[:len(merged)-1] | |
| 1051 | + | changed = true | |
| 1054 | 1052 | } | |
| 1055 | 1053 | } | |
| 1056 | 1054 | } | |
M internal/infrastructure/cef/scheme_handler.go +7 -6
| @@ -28,11 +28,12 @@ import ( | |||
| 28 | 28 | ||
| 29 | 29 | // Scheme path constants matching WebKit's naming. | |
| 30 | 30 | const ( | |
| 31 | - | homePath = "home" | |
| 32 | - | configPath = "config" | |
| 33 | - | webrtcPath = "webrtc" | |
| 34 | - | errorPath = "error" | |
| 35 | - | indexHTML = "index.html" | |
| 31 | + | homePath = "home" | |
| 32 | + | configPath = "config" | |
| 33 | + | webrtcPath = "webrtc" | |
| 34 | + | errorPath = "error" | |
| 35 | + | indexHTML = "index.html" | |
| 36 | + | maxSchemeTruncatedURLLength = 240 | |
| 36 | 37 | ) | |
| 37 | 38 | ||
| 38 | 39 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | |
| @@ -192,7 +193,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 192 | 193 | ||
| 193 | 194 | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | |
| 194 | 195 | h.logger.Info(). | |
| 195 | - | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |
| 196 | + | Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)). | |
| 196 | 197 | Int("forwarded_header_count", len(headers)). | |
| 197 | 198 | Msg("scheme handler returning transcoding stream") | |
| 198 | 199 | ||
M internal/infrastructure/cef/transcoding_handler.go +64 -26
| @@ -2,6 +2,7 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| @@ -22,6 +23,11 @@ import ( | |||
| 22 | 23 | ||
| 23 | 24 | var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | |
| 24 | 25 | ||
| 26 | + | const ( | |
| 27 | + | maxTranscodingURLLength = 240 | |
| 28 | + | httpStatusOK = 200 | |
| 29 | + | ) | |
| 30 | + | ||
| 25 | 31 | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | |
| 26 | 32 | // accepts a purecef.ResourceHandler, and the struct implements all methods. | |
| 27 | 33 | ||
| @@ -42,13 +48,16 @@ type transcodingResourceHandler struct { | |||
| 42 | 48 | ||
| 43 | 49 | // Open starts the transcode session asynchronously. CEF will call | |
| 44 | 50 | // GetResponseHeaders and Read once the callback fires. | |
| 45 | - | func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback) int32 { | |
| 51 | + | func (rh *transcodingResourceHandler) Open( | |
| 52 | + | _ purecef.Request, handleRequest unsafe.Pointer, callback purecef.Callback, | |
| 53 | + | ) int32 { | |
| 46 | 54 | // Signal async handling: set handleRequest = 0. | |
| 47 | 55 | *(*int32)(handleRequest) = 0 | |
| 48 | 56 | ||
| 49 | 57 | log := rh.logger() | |
| 58 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 50 | 59 | log.Info(). | |
| 51 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 60 | + | Str("source_url", sourceURL). | |
| 52 | 61 | Int("forwarded_header_count", len(rh.headers)). | |
| 53 | 62 | Msg("cef: starting transcoding resource stream") | |
| 54 | 63 | ||
| @@ -57,7 +66,7 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||
| 57 | 66 | if err != nil { | |
| 58 | 67 | log.Error(). | |
| 59 | 68 | Err(err). | |
| 60 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 69 | + | Str("source_url", sourceURL). | |
| 61 | 70 | Msg("cef: failed to start transcoding resource stream") | |
| 62 | 71 | // Let CEF know we failed — Cancel will be called. | |
| 63 | 72 | rh.cancel() | |
| @@ -66,7 +75,7 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||
| 66 | 75 | } | |
| 67 | 76 | rh.session = session | |
| 68 | 77 | log.Info(). | |
| 69 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 78 | + | Str("source_url", sourceURL). | |
| 70 | 79 | Str("content_type", session.ContentType()). | |
| 71 | 80 | Msg("cef: transcoding resource stream ready") | |
| 72 | 81 | callback.Cont() | |
| @@ -76,8 +85,10 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||
| 76 | 85 | } | |
| 77 | 86 | ||
| 78 | 87 | // GetResponseHeaders sets the streaming response metadata. | |
| 79 | - | func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) { | |
| 80 | - | response.SetStatus(200) | |
| 88 | + | func (rh *transcodingResourceHandler) GetResponseHeaders( | |
| 89 | + | response purecef.Response, responseLength unsafe.Pointer, _ uintptr, | |
| 90 | + | ) { | |
| 91 | + | response.SetStatus(httpStatusOK) | |
| 81 | 92 | response.SetStatusText("OK") | |
| 82 | 93 | response.SetMimeType("video/webm") | |
| 83 | 94 | response.SetHeaderByName("Accept-Ranges", "none", 1) | |
| @@ -105,7 +116,7 @@ func (rh *transcodingResourceHandler) Read( | |||
| 105 | 116 | rh.totalBytes += int64(n) | |
| 106 | 117 | if rh.totalBytes == int64(n) { | |
| 107 | 118 | rh.logger().Info(). | |
| 108 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 119 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | |
| 109 | 120 | Int("first_chunk_bytes", n). | |
| 110 | 121 | Msg("cef: transcoding resource stream produced first bytes") | |
| 111 | 122 | } | |
| @@ -113,14 +124,15 @@ func (rh *transcodingResourceHandler) Read( | |||
| 113 | 124 | return 1 | |
| 114 | 125 | } | |
| 115 | 126 | if err != nil { | |
| 127 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 116 | 128 | log := rh.logger().With(). | |
| 117 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 129 | + | Str("source_url", sourceURL). | |
| 118 | 130 | Int64("total_bytes", rh.totalBytes). | |
| 119 | 131 | Logger() | |
| 120 | 132 | switch { | |
| 121 | - | case err == io.EOF && rh.totalBytes == 0: | |
| 133 | + | case errors.Is(err, io.EOF) && rh.totalBytes == 0: | |
| 122 | 134 | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |
| 123 | - | case err == io.EOF: | |
| 135 | + | case errors.Is(err, io.EOF): | |
| 124 | 136 | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |
| 125 | 137 | default: | |
| 126 | 138 | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | |
| @@ -132,12 +144,18 @@ func (rh *transcodingResourceHandler) Read( | |||
| 132 | 144 | ||
| 133 | 145 | // Cancel aborts the transcode session and releases resources. | |
| 134 | 146 | func (rh *transcodingResourceHandler) Cancel() { | |
| 147 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 135 | 148 | rh.logger().Debug(). | |
| 136 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | |
| 149 | + | Str("source_url", sourceURL). | |
| 137 | 150 | Int64("total_bytes", rh.totalBytes). | |
| 138 | - | Msg("cef: transcoding resource stream cancelled") | |
| 151 | + | Msg("cef: transcoding resource stream canceled") | |
| 139 | 152 | if rh.session != nil { | |
| 140 | - | rh.session.Close() | |
| 153 | + | if err := rh.session.Close(); err != nil { | |
| 154 | + | rh.logger().Warn(). | |
| 155 | + | Str("source_url", sourceURL). | |
| 156 | + | Err(err). | |
| 157 | + | Msg("cef: transcoding resource stream close failed") | |
| 158 | + | } | |
| 141 | 159 | } | |
| 142 | 160 | if rh.cancel != nil { | |
| 143 | 161 | rh.cancel() | |
| @@ -178,7 +196,9 @@ type requestInfo struct { | |||
| 178 | 196 | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | |
| 179 | 197 | // proprietary video containers and HLS/DASH manifest entrypoints, then | |
| 180 | 198 | // restarts those requests through a GPU transcoding ResourceHandler. | |
| 181 | - | func newTranscodingRequestHandler(tc port.MediaTranscoder, ctxf func() context.Context) purecef.ResourceRequestHandler { | |
| 199 | + | func newTranscodingRequestHandler( | |
| 200 | + | tc port.MediaTranscoder, ctxf func() context.Context, | |
| 201 | + | ) purecef.ResourceRequestHandler { | |
| 182 | 202 | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | |
| 183 | 203 | transcoder: tc, | |
| 184 | 204 | ctxf: ctxf, | |
| @@ -189,7 +209,9 @@ func newTranscodingRequestHandler(tc port.MediaTranscoder, ctxf func() context.C | |||
| 189 | 209 | // matches a proprietary video format or a streaming manifest entrypoint, it | |
| 190 | 210 | // stores the request info and returns 1 to restart the request — at which | |
| 191 | 211 | // point GetResourceHandler will provide the transcoding handler. | |
| 192 | - | func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response) int32 { | |
| 212 | + | func (h *transcodingRequestHandler) OnResourceResponse( | |
| 213 | + | _ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response, | |
| 214 | + | ) int32 { | |
| 193 | 215 | if response == nil || request == nil { | |
| 194 | 216 | return 0 | |
| 195 | 217 | } | |
| @@ -202,7 +224,7 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure | |||
| 202 | 224 | isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url)) | |
| 203 | 225 | if isLikelyMediaResponse(url, mimeType) { | |
| 204 | 226 | log.Debug(). | |
| 205 | - | Str("url", logging.TruncateURL(url, 240)). | |
| 227 | + | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 206 | 228 | Str("mime_type", mimeType). | |
| 207 | 229 | Bool("proprietary_match", isProprietary). | |
| 208 | 230 | Bool("already_open", isAlreadyOpen). | |
| @@ -225,7 +247,7 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure | |||
| 225 | 247 | reason = "streaming-manifest" | |
| 226 | 248 | } | |
| 227 | 249 | log.Info(). | |
| 228 | - | Str("url", logging.TruncateURL(url, 240)). | |
| 250 | + | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |
| 229 | 251 | Str("mime_type", mimeType). | |
| 230 | 252 | Str("transcode_reason", reason). | |
| 231 | 253 | Int("forwarded_header_count", len(info.headers)). | |
| @@ -236,7 +258,9 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure | |||
| 236 | 258 | ||
| 237 | 259 | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | |
| 238 | 260 | // either identified eagerly by URL heuristics or marked by OnResourceResponse. | |
| 239 | - | func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ purecef.Frame, request purecef.Request) purecef.ResourceHandler { | |
| 261 | + | func (h *transcodingRequestHandler) GetResourceHandler( | |
| 262 | + | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 263 | + | ) purecef.ResourceHandler { | |
| 240 | 264 | if request == nil { | |
| 241 | 265 | return nil | |
| 242 | 266 | } | |
| @@ -275,9 +299,11 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure | |||
| 275 | 299 | } | |
| 276 | 300 | } | |
| 277 | 301 | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | |
| 302 | + | shortURL := logging.TruncateURL(url, maxTranscodingURLLength) | |
| 303 | + | shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength) | |
| 278 | 304 | h.logger().Info(). | |
| 279 | - | Str("url", logging.TruncateURL(url, 240)). | |
| 280 | - | Str("source_url", logging.TruncateURL(sourceURL, 240)). | |
| 305 | + | Str("url", shortURL). | |
| 306 | + | Str("source_url", shortSourceURL). | |
| 281 | 307 | Bool("eager_match", eager). | |
| 282 | 308 | Int("forwarded_header_count", len(info.headers)). | |
| 283 | 309 | Msg("cef: GetResourceHandler returning transcoding resource handler") | |
| @@ -293,22 +319,33 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure | |||
| 293 | 319 | ||
| 294 | 320 | // --- No-op methods required by purecef.ResourceRequestHandler --- | |
| 295 | 321 | ||
| 296 | - | func (h *transcodingRequestHandler) GetCookieAccessFilter(_ purecef.Browser, _ purecef.Frame, _ purecef.Request) purecef.CookieAccessFilter { | |
| 322 | + | func (h *transcodingRequestHandler) GetCookieAccessFilter( | |
| 323 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |
| 324 | + | ) purecef.CookieAccessFilter { | |
| 297 | 325 | return nil | |
| 298 | 326 | } | |
| 299 | 327 | ||
| 300 | - | func (h *transcodingRequestHandler) OnBeforeResourceLoad(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback) purecef.ReturnValue { | |
| 328 | + | func (h *transcodingRequestHandler) OnBeforeResourceLoad( | |
| 329 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback, | |
| 330 | + | ) purecef.ReturnValue { | |
| 301 | 331 | return purecef.ReturnValueRvContinue | |
| 302 | 332 | } | |
| 303 | 333 | ||
| 304 | - | func (h *transcodingRequestHandler) OnResourceRedirect(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr) { | |
| 334 | + | func (h *transcodingRequestHandler) OnResourceRedirect( | |
| 335 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr, | |
| 336 | + | ) { | |
| 305 | 337 | } | |
| 306 | 338 | ||
| 307 | - | func (h *transcodingRequestHandler) GetResourceResponseFilter(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response) purecef.ResponseFilter { | |
| 339 | + | func (h *transcodingRequestHandler) GetResourceResponseFilter( | |
| 340 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |
| 341 | + | ) purecef.ResponseFilter { | |
| 308 | 342 | return nil | |
| 309 | 343 | } | |
| 310 | 344 | ||
| 311 | - | func (h *transcodingRequestHandler) OnResourceLoadComplete(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ purecef.UrlrequestStatus, _ int64) { | |
| 345 | + | func (h *transcodingRequestHandler) OnResourceLoadComplete( | |
| 346 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |
| 347 | + | _ purecef.UrlrequestStatus, _ int64, | |
| 348 | + | ) { | |
| 312 | 349 | } | |
| 313 | 350 | ||
| 314 | 351 | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) { | |
| @@ -361,7 +398,8 @@ func (h *transcodingRequestHandler) logf() zerolog.Logger { | |||
| 361 | 398 | ||
| 362 | 399 | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | |
| 363 | 400 | if rh != nil && rh.logf != nil { | |
| 364 | - | logger := rh.logf().With().Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).Logger() | |
| 401 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 402 | + | logger := rh.logf().With().Str("source_url", sourceURL).Logger() | |
| 365 | 403 | return &logger | |
| 366 | 404 | } | |
| 367 | 405 | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | |
M internal/infrastructure/cef/webview.go +28 -0
| @@ -80,6 +80,15 @@ type WebView struct { | |||
| 80 | 80 | generation atomic.Uint64 | |
| 81 | 81 | audioPlaying atomic.Bool | |
| 82 | 82 | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| 83 | + | ||
| 84 | + | // Audio output factory and active stream. | |
| 85 | + | audioOutputFactory port.AudioOutputFactory | |
| 86 | + | audioStreamMu sync.Mutex | |
| 87 | + | activeAudioStream port.AudioOutputStream | |
| 88 | + | ||
| 89 | + | // Audio instrumentation counters (diagnostic only). | |
| 90 | + | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | |
| 91 | + | audioWriteCount atomic.Uint64 // successful Write calls to stream | |
| 83 | 92 | } | |
| 84 | 93 | ||
| 85 | 94 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| @@ -463,6 +472,7 @@ func (wv *WebView) Destroy() { | |||
| 463 | 472 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 464 | 473 | return | |
| 465 | 474 | } | |
| 475 | + | wv.closeAudioStream() | |
| 466 | 476 | wv.scheduleStopBeginFrameLoop() | |
| 467 | 477 | wv.mu.RLock() | |
| 468 | 478 | host := wv.host | |
| @@ -705,3 +715,21 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||
| 705 | 715 | wv.pendingCreate = nil | |
| 706 | 716 | return pc | |
| 707 | 717 | } | |
| 718 | + | ||
| 719 | + | // closeAudioStream closes and clears the active audio output stream. | |
| 720 | + | // This is safe to call even if no stream is active. | |
| 721 | + | func (wv *WebView) closeAudioStream() { | |
| 722 | + | wv.audioStreamMu.Lock() | |
| 723 | + | defer wv.audioStreamMu.Unlock() | |
| 724 | + | ||
| 725 | + | if wv.activeAudioStream != nil { | |
| 726 | + | if err := wv.activeAudioStream.Close(); err != nil { | |
| 727 | + | if wv.ctx != nil { | |
| 728 | + | logging.FromContext(wv.ctx).Debug(). | |
| 729 | + | Err(err). | |
| 730 | + | Msg("cef: error closing audio stream") | |
| 731 | + | } | |
| 732 | + | } | |
| 733 | + | wv.activeAudioStream = nil | |
| 734 | + | } | |
| 735 | + | } | |
A internal/infrastructure/cef/webview_audio_test.go +634 -0
| @@ -0,0 +1,634 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/audio/null" | |
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // TestEngine_WiresAudioOutputFactory verifies that the audio output factory | |
| 17 | + | // is properly injected through engine initialization. | |
| 18 | + | func TestEngine_WiresAudioOutputFactory(t *testing.T) { | |
| 19 | + | // This test verifies the wiring pattern. Since we can't easily | |
| 20 | + | // initialize a full CEF engine in unit tests, we verify the | |
| 21 | + | // webViewFactoryOptions struct accepts the audio factory. | |
| 22 | + | audioFactory := &null.Factory{} | |
| 23 | + | ||
| 24 | + | // Verify webViewFactoryOptions can hold the audio factory | |
| 25 | + | opts := webViewFactoryOptions{ | |
| 26 | + | scale: 1, | |
| 27 | + | windowlessFrameRate: 60, | |
| 28 | + | audioOutputFactory: audioFactory, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | require.NotNil(t, opts.audioOutputFactory) | |
| 32 | + | assert.Equal(t, audioFactory, opts.audioOutputFactory) | |
| 33 | + | ||
| 34 | + | // Verify the factory is passed through | |
| 35 | + | factory := newWebViewFactory(&Engine{}, &glLoader{}, opts) | |
| 36 | + | require.NotNil(t, factory.audioOutputFactory) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // TestWebViewFactory_AudioFactoryIsOptional verifies that nil audio factory | |
| 40 | + | // is handled gracefully (no panic, no audio output). | |
| 41 | + | func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) { | |
| 42 | + | // Arrange | |
| 43 | + | opts := webViewFactoryOptions{ | |
| 44 | + | scale: 1, | |
| 45 | + | windowlessFrameRate: 60, | |
| 46 | + | audioOutputFactory: nil, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Act | |
| 50 | + | factory := newWebViewFactory(&Engine{}, &glLoader{}, opts) | |
| 51 | + | ||
| 52 | + | // Assert | |
| 53 | + | require.NotNil(t, factory) | |
| 54 | + | assert.Nil(t, factory.audioOutputFactory) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // TestHandlerSet_ReceivesAudioFactory verifies the handler set receives | |
| 58 | + | // the audio factory from the WebView. | |
| 59 | + | func TestHandlerSet_ReceivesAudioFactory(t *testing.T) { | |
| 60 | + | // This test verifies that handlerSet can access the audio factory | |
| 61 | + | // through its associated WebView. | |
| 62 | + | audioFactory := &null.Factory{} | |
| 63 | + | ||
| 64 | + | wv := &WebView{ | |
| 65 | + | ctx: context.Background(), | |
| 66 | + | audioOutputFactory: audioFactory, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | handlers := &handlerSet{wv: wv} | |
| 70 | + | ||
| 71 | + | // Verify the handler can access the audio factory through the WebView | |
| 72 | + | require.NotNil(t, handlers.wv) | |
| 73 | + | assert.Equal(t, audioFactory, handlers.wv.audioOutputFactory) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // TestWebView_AudioStreamLifecycleFieldsExist verifies the WebView has | |
| 77 | + | // the necessary fields for audio stream lifecycle management. | |
| 78 | + | func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) { | |
| 79 | + | wv := &WebView{ | |
| 80 | + | ctx: context.Background(), | |
| 81 | + | audioOutputFactory: &null.Factory{}, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Verify the fields exist and can be set | |
| 85 | + | wv.activeAudioStream = &mockAudioStream{} | |
| 86 | + | ||
| 87 | + | assert.NotNil(t, wv.audioOutputFactory) | |
| 88 | + | assert.NotNil(t, wv.activeAudioStream) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // mockAudioStream is a test double for port.AudioOutputStream | |
| 92 | + | type mockAudioStream struct { | |
| 93 | + | writeCalled bool | |
| 94 | + | closeCalled bool | |
| 95 | + | writeSamples [][]float32 | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (m *mockAudioStream) Write(samples [][]float32) error { | |
| 99 | + | m.writeCalled = true | |
| 100 | + | m.writeSamples = samples | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (m *mockAudioStream) Close() error { | |
| 105 | + | m.closeCalled = true | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Verify mock implements the interface | |
| 110 | + | var _ port.AudioOutputStream = (*mockAudioStream)(nil) | |
| 111 | + | ||
| 112 | + | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | |
| 113 | + | // tests to verify that the caller holds a lock across the Write call. | |
| 114 | + | type racyMockAudioStream struct { | |
| 115 | + | writeCh chan struct{} // closed when Write is entered | |
| 116 | + | doneCh chan struct{} // Write blocks until this is closed | |
| 117 | + | closeCalled bool | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (m *racyMockAudioStream) Write(_ [][]float32) error { | |
| 121 | + | close(m.writeCh) // signal that Write has been entered | |
| 122 | + | <-m.doneCh // block until test unblocks us | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (m *racyMockAudioStream) Close() error { | |
| 127 | + | m.closeCalled = true | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | var _ port.AudioOutputStream = (*racyMockAudioStream)(nil) | |
| 132 | + | ||
| 133 | + | // ============================================================================ | |
| 134 | + | // Task 6: Audio Stream Lifecycle Handling Tests | |
| 135 | + | // ============================================================================ | |
| 136 | + | ||
| 137 | + | // TestHandlerSet_OnAudioStreamStarted_CreatesStream verifies that | |
| 138 | + | // OnAudioStreamStarted creates a new audio stream from the factory. | |
| 139 | + | func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) { | |
| 140 | + | ctx := context.Background() | |
| 141 | + | mockStream := &mockAudioStream{} | |
| 142 | + | mockFactory := &mockAudioFactory{stream: mockStream} | |
| 143 | + | ||
| 144 | + | wv := &WebView{ | |
| 145 | + | ctx: ctx, | |
| 146 | + | audioOutputFactory: mockFactory, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | handlers := &handlerSet{wv: wv} | |
| 150 | + | ||
| 151 | + | params := &purecef.AudioParameters{ | |
| 152 | + | SampleRate: 48000, | |
| 153 | + | ChannelLayout: purecef.ChannelLayoutStereo, | |
| 154 | + | FramesPerBuffer: 512, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Act — third arg is channels, not framesPerBuffer | |
| 158 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |
| 159 | + | ||
| 160 | + | // Assert | |
| 161 | + | require.NotNil(t, wv.activeAudioStream) | |
| 162 | + | assert.Equal(t, mockStream, wv.activeAudioStream) | |
| 163 | + | assert.True(t, wv.audioPlaying.Load()) | |
| 164 | + | ||
| 165 | + | // Verify factory was called with correct format | |
| 166 | + | require.NotNil(t, mockFactory.lastFormat) | |
| 167 | + | assert.Equal(t, 48000, mockFactory.lastFormat.SampleRate) | |
| 168 | + | assert.Equal(t, 2, mockFactory.lastFormat.ChannelCount) | |
| 169 | + | assert.Equal(t, 512, mockFactory.lastFormat.FramesPerBuffer) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // TestHandlerSet_OnAudioStreamStarted_NoFactory_DoesNotPanic verifies | |
| 173 | + | // that OnAudioStreamStarted handles nil factory gracefully. | |
| 174 | + | func TestHandlerSet_OnAudioStreamStarted_NoFactory_DoesNotPanic(t *testing.T) { | |
| 175 | + | wv := &WebView{ | |
| 176 | + | ctx: context.Background(), | |
| 177 | + | audioOutputFactory: nil, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | handlers := &handlerSet{wv: wv} | |
| 181 | + | ||
| 182 | + | params := &purecef.AudioParameters{ | |
| 183 | + | SampleRate: 48000, | |
| 184 | + | ChannelLayout: purecef.ChannelLayoutStereo, | |
| 185 | + | FramesPerBuffer: 512, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Should not panic | |
| 189 | + | assert.NotPanics(t, func() { | |
| 190 | + | handlers.OnAudioStreamStarted(nil, params, 512) | |
| 191 | + | }) | |
| 192 | + | ||
| 193 | + | assert.False(t, wv.audioPlaying.Load()) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream verifies | |
| 197 | + | // that starting a new stream closes any existing one. | |
| 198 | + | func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |
| 199 | + | ctx := context.Background() | |
| 200 | + | oldStream := &mockAudioStream{} | |
| 201 | + | newStream := &mockAudioStream{} | |
| 202 | + | mockFactory := &mockAudioFactory{stream: newStream} | |
| 203 | + | ||
| 204 | + | wv := &WebView{ | |
| 205 | + | ctx: ctx, | |
| 206 | + | audioOutputFactory: mockFactory, | |
| 207 | + | activeAudioStream: oldStream, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | handlers := &handlerSet{wv: wv} | |
| 211 | + | ||
| 212 | + | params := &purecef.AudioParameters{ | |
| 213 | + | SampleRate: 44100, | |
| 214 | + | ChannelLayout: purecef.ChannelLayoutMono, | |
| 215 | + | FramesPerBuffer: 256, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Act — third arg is channels (1 for mono) | |
| 219 | + | handlers.OnAudioStreamStarted(nil, params, 1) | |
| 220 | + | ||
| 221 | + | // Assert | |
| 222 | + | assert.True(t, oldStream.closeCalled) | |
| 223 | + | assert.Equal(t, newStream, wv.activeAudioStream) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | |
| 227 | + | // that factory errors are handled without panic and audioPlaying is reset to false. | |
| 228 | + | func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testing.T) { | |
| 229 | + | ctx := context.Background() | |
| 230 | + | mockFactory := &mockAudioFactory{err: errors.New("factory error")} | |
| 231 | + | ||
| 232 | + | wv := &WebView{ | |
| 233 | + | ctx: ctx, | |
| 234 | + | audioOutputFactory: mockFactory, | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | handlers := &handlerSet{wv: wv} | |
| 238 | + | ||
| 239 | + | params := &purecef.AudioParameters{ | |
| 240 | + | SampleRate: 48000, | |
| 241 | + | ChannelLayout: purecef.ChannelLayoutStereo, | |
| 242 | + | FramesPerBuffer: 512, | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // Should not panic | |
| 246 | + | assert.NotPanics(t, func() { | |
| 247 | + | handlers.OnAudioStreamStarted(nil, params, 512) | |
| 248 | + | }) | |
| 249 | + | ||
| 250 | + | // audioPlaying must be false when NewStream fails — if it stays true, | |
| 251 | + | // the UI/state layer incorrectly believes audio is active. | |
| 252 | + | assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure") | |
| 253 | + | assert.Nil(t, wv.activeAudioStream, "activeAudioStream must be nil after NewStream failure") | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // ============================================================================ | |
| 257 | + | // Task 7: PCM Packet Forwarding Tests | |
| 258 | + | // ============================================================================ | |
| 259 | + | ||
| 260 | + | // TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites verifies that | |
| 261 | + | // OnAudioStreamPacket copies samples before writing to avoid data races. | |
| 262 | + | func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) { | |
| 263 | + | ctx := context.Background() | |
| 264 | + | mockStream := &mockAudioStream{} | |
| 265 | + | ||
| 266 | + | wv := &WebView{ | |
| 267 | + | ctx: ctx, | |
| 268 | + | activeAudioStream: mockStream, | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | handlers := &handlerSet{wv: wv} | |
| 272 | + | ||
| 273 | + | // Create sample data: 2 channels, 512 frames each | |
| 274 | + | originalData := [][]float32{ | |
| 275 | + | make([]float32, 512), // Channel 0 | |
| 276 | + | make([]float32, 512), // Channel 1 | |
| 277 | + | } | |
| 278 | + | originalData[0][0] = 0.5 | |
| 279 | + | originalData[1][0] = -0.5 | |
| 280 | + | ||
| 281 | + | // Act | |
| 282 | + | handlers.OnAudioStreamPacket(nil, originalData, 512, 0) | |
| 283 | + | ||
| 284 | + | // Assert | |
| 285 | + | require.True(t, mockStream.writeCalled) | |
| 286 | + | require.Len(t, mockStream.writeSamples, 2) | |
| 287 | + | require.Len(t, mockStream.writeSamples[0], 512) | |
| 288 | + | ||
| 289 | + | // Verify data was copied (values match) | |
| 290 | + | assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001) | |
| 291 | + | assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001) | |
| 292 | + | ||
| 293 | + | // Verify it's a copy (different underlying array) | |
| 294 | + | assert.NotSame(t, &originalData[0][0], &mockStream.writeSamples[0][0]) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies | |
| 298 | + | // that packets are silently discarded when no stream is active. | |
| 299 | + | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | |
| 300 | + | wv := &WebView{ | |
| 301 | + | ctx: context.Background(), | |
| 302 | + | activeAudioStream: nil, | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | handlers := &handlerSet{wv: wv} | |
| 306 | + | ||
| 307 | + | data := [][]float32{ | |
| 308 | + | {0.1, 0.2, 0.3}, | |
| 309 | + | {0.4, 0.5, 0.6}, | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // Should not panic | |
| 313 | + | assert.NotPanics(t, func() { | |
| 314 | + | handlers.OnAudioStreamPacket(nil, data, 3, 0) | |
| 315 | + | }) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | |
| 319 | + | // that nil data is handled gracefully. | |
| 320 | + | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |
| 321 | + | mockStream := &mockAudioStream{} | |
| 322 | + | wv := &WebView{ | |
| 323 | + | ctx: context.Background(), | |
| 324 | + | activeAudioStream: mockStream, | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | handlers := &handlerSet{wv: wv} | |
| 328 | + | ||
| 329 | + | // Should not panic with nil data | |
| 330 | + | assert.NotPanics(t, func() { | |
| 331 | + | handlers.OnAudioStreamPacket(nil, nil, 0, 0) | |
| 332 | + | }) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | // ============================================================================ | |
| 336 | + | // Task 8: Stream Cleanup Tests | |
| 337 | + | // ============================================================================ | |
| 338 | + | ||
| 339 | + | // TestHandlerSet_OnAudioStreamStopped_ClosesStream verifies that | |
| 340 | + | // OnAudioStreamStopped closes the active stream. | |
| 341 | + | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |
| 342 | + | mockStream := &mockAudioStream{} | |
| 343 | + | wv := &WebView{ | |
| 344 | + | ctx: context.Background(), | |
| 345 | + | activeAudioStream: mockStream, | |
| 346 | + | } | |
| 347 | + | wv.audioPlaying.Store(true) | |
| 348 | + | ||
| 349 | + | handlers := &handlerSet{wv: wv} | |
| 350 | + | ||
| 351 | + | // Act | |
| 352 | + | handlers.OnAudioStreamStopped(nil) | |
| 353 | + | ||
| 354 | + | // Assert | |
| 355 | + | assert.True(t, mockStream.closeCalled) | |
| 356 | + | assert.Nil(t, wv.activeAudioStream) | |
| 357 | + | assert.False(t, wv.audioPlaying.Load()) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic verifies | |
| 361 | + | // that stopping when no stream exists doesn't panic. | |
| 362 | + | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | |
| 363 | + | wv := &WebView{ | |
| 364 | + | ctx: context.Background(), | |
| 365 | + | activeAudioStream: nil, | |
| 366 | + | } | |
| 367 | + | wv.audioPlaying.Store(true) | |
| 368 | + | ||
| 369 | + | handlers := &handlerSet{wv: wv} | |
| 370 | + | ||
| 371 | + | // Should not panic | |
| 372 | + | assert.NotPanics(t, func() { | |
| 373 | + | handlers.OnAudioStreamStopped(nil) | |
| 374 | + | }) | |
| 375 | + | ||
| 376 | + | assert.False(t, wv.audioPlaying.Load()) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // TestHandlerSet_OnAudioStreamError_ClosesStream verifies that | |
| 380 | + | // OnAudioStreamError closes the active stream. | |
| 381 | + | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |
| 382 | + | mockStream := &mockAudioStream{} | |
| 383 | + | wv := &WebView{ | |
| 384 | + | ctx: context.Background(), | |
| 385 | + | activeAudioStream: mockStream, | |
| 386 | + | } | |
| 387 | + | wv.audioPlaying.Store(true) | |
| 388 | + | ||
| 389 | + | handlers := &handlerSet{wv: wv} | |
| 390 | + | ||
| 391 | + | // Act | |
| 392 | + | handlers.OnAudioStreamError(nil, "test error") | |
| 393 | + | ||
| 394 | + | // Assert | |
| 395 | + | assert.True(t, mockStream.closeCalled) | |
| 396 | + | assert.Nil(t, wv.activeAudioStream) | |
| 397 | + | assert.False(t, wv.audioPlaying.Load()) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic verifies | |
| 401 | + | // that error handling when no stream exists doesn't panic. | |
| 402 | + | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | |
| 403 | + | wv := &WebView{ | |
| 404 | + | ctx: context.Background(), | |
| 405 | + | activeAudioStream: nil, | |
| 406 | + | } | |
| 407 | + | wv.audioPlaying.Store(true) | |
| 408 | + | ||
| 409 | + | handlers := &handlerSet{wv: wv} | |
| 410 | + | ||
| 411 | + | // Should not panic | |
| 412 | + | assert.NotPanics(t, func() { | |
| 413 | + | handlers.OnAudioStreamError(nil, "test error") | |
| 414 | + | }) | |
| 415 | + | ||
| 416 | + | assert.False(t, wv.audioPlaying.Load()) | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // TestWebView_Destroy_ClosesAudioStream verifies that destroying | |
| 420 | + | // the WebView closes any active audio stream. | |
| 421 | + | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |
| 422 | + | mockStream := &mockAudioStream{} | |
| 423 | + | wv := &WebView{ | |
| 424 | + | ctx: context.Background(), | |
| 425 | + | activeAudioStream: mockStream, | |
| 426 | + | pipeline: &renderPipeline{}, | |
| 427 | + | } | |
| 428 | + | wv.audioPlaying.Store(true) | |
| 429 | + | ||
| 430 | + | // Act — Destroy must close the audio stream as part of teardown. | |
| 431 | + | wv.Destroy() | |
| 432 | + | ||
| 433 | + | // Assert | |
| 434 | + | assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream") | |
| 435 | + | assert.Nil(t, wv.activeAudioStream) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | |
| 439 | + | // handles the case where no audio stream is active. | |
| 440 | + | func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) { | |
| 441 | + | wv := &WebView{ | |
| 442 | + | ctx: context.Background(), | |
| 443 | + | pipeline: &renderPipeline{}, | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | assert.NotPanics(t, func() { | |
| 447 | + | wv.Destroy() | |
| 448 | + | }) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // ============================================================================ | |
| 452 | + | // Callback parameter mapping tests | |
| 453 | + | // ============================================================================ | |
| 454 | + | ||
| 455 | + | // TestBuildAudioStreamFormat_UsesParamsFramesPerBuffer verifies that | |
| 456 | + | // buildAudioStreamFormat reads FramesPerBuffer from the AudioParameters struct, | |
| 457 | + | // NOT from the callback's third argument (which is channels). | |
| 458 | + | func TestBuildAudioStreamFormat_UsesParamsFramesPerBuffer(t *testing.T) { | |
| 459 | + | wv := &WebView{ctx: context.Background()} | |
| 460 | + | handlers := &handlerSet{wv: wv} | |
| 461 | + | ||
| 462 | + | params := &purecef.AudioParameters{ | |
| 463 | + | SampleRate: 48000, | |
| 464 | + | ChannelLayout: purecef.ChannelLayoutStereo, | |
| 465 | + | FramesPerBuffer: 1024, | |
| 466 | + | } | |
| 467 | + | channels := int32(2) // third callback arg is channels, not framesPerBuffer | |
| 468 | + | ||
| 469 | + | format := handlers.buildAudioStreamFormat(params, channels) | |
| 470 | + | ||
| 471 | + | assert.Equal(t, 48000, format.SampleRate, "sample rate should come from params") | |
| 472 | + | assert.Equal(t, 2, format.ChannelCount, "channel count should match callback channels arg") | |
| 473 | + | assert.Equal(t, 1024, format.FramesPerBuffer, "frames_per_buffer should come from params.FramesPerBuffer, not the channels arg") | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // TestBuildAudioStreamFormat_ChannelsArgDiffersFromLayout verifies correct | |
| 477 | + | // behavior when the callback's channels arg and the layout-derived count differ. | |
| 478 | + | // The channels callback arg is the authoritative channel count. | |
| 479 | + | func TestBuildAudioStreamFormat_ChannelsArgDiffersFromLayout(t *testing.T) { | |
| 480 | + | wv := &WebView{ctx: context.Background()} | |
| 481 | + | handlers := &handlerSet{wv: wv} | |
| 482 | + | ||
| 483 | + | params := &purecef.AudioParameters{ | |
| 484 | + | SampleRate: 44100, | |
| 485 | + | ChannelLayout: purecef.ChannelLayoutStereo, // layout says 2 | |
| 486 | + | FramesPerBuffer: 512, | |
| 487 | + | } | |
| 488 | + | channels := int32(1) // but CEF says 1 channel | |
| 489 | + | ||
| 490 | + | format := handlers.buildAudioStreamFormat(params, channels) | |
| 491 | + | ||
| 492 | + | assert.Equal(t, 44100, format.SampleRate) | |
| 493 | + | assert.Equal(t, 1, format.ChannelCount, "should use callback channels arg, not layout-derived count") | |
| 494 | + | assert.Equal(t, 512, format.FramesPerBuffer, "should use params.FramesPerBuffer") | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // TestOnAudioStreamStarted_CorrectParameterMapping verifies end-to-end that | |
| 498 | + | // OnAudioStreamStarted passes the correct values to the factory: | |
| 499 | + | // - channels from the callback arg (not from params.FramesPerBuffer) | |
| 500 | + | // - framesPerBuffer from params.FramesPerBuffer | |
| 501 | + | func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) { | |
| 502 | + | ctx := context.Background() | |
| 503 | + | mockStream := &mockAudioStream{} | |
| 504 | + | mockFactory := &mockAudioFactory{stream: mockStream} | |
| 505 | + | ||
| 506 | + | wv := &WebView{ | |
| 507 | + | ctx: ctx, | |
| 508 | + | audioOutputFactory: mockFactory, | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | handlers := &handlerSet{wv: wv} | |
| 512 | + | ||
| 513 | + | params := &purecef.AudioParameters{ | |
| 514 | + | SampleRate: 48000, | |
| 515 | + | ChannelLayout: purecef.ChannelLayoutStereo, | |
| 516 | + | FramesPerBuffer: 1024, | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // Third arg is channels=2, NOT framesPerBuffer. | |
| 520 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |
| 521 | + | ||
| 522 | + | require.NotNil(t, mockFactory.lastFormat) | |
| 523 | + | assert.Equal(t, 48000, mockFactory.lastFormat.SampleRate) | |
| 524 | + | assert.Equal(t, 2, mockFactory.lastFormat.ChannelCount, "channel count should be 2 (from callback arg)") | |
| 525 | + | assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)") | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // ============================================================================ | |
| 529 | + | // Race-window tests (write/close interleave) | |
| 530 | + | // ============================================================================ | |
| 531 | + | ||
| 532 | + | // TestOnAudioStreamPacket_WriteHoldsLock verifies that closeAudioStream cannot | |
| 533 | + | // interleave between the stream snapshot and Write. If the lock is NOT held | |
| 534 | + | // across Write, the goroutine calling closeAudioStream will Close the stream | |
| 535 | + | // while Write is in-flight, producing a write-after-close. | |
| 536 | + | func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) { | |
| 537 | + | ctx := context.Background() | |
| 538 | + | ||
| 539 | + | // racyStream blocks inside Write until we signal, giving closeAudioStream | |
| 540 | + | // a window to race if the mutex isn't held. | |
| 541 | + | stream := &racyMockAudioStream{ | |
| 542 | + | writeCh: make(chan struct{}), | |
| 543 | + | doneCh: make(chan struct{}), | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | wv := &WebView{ | |
| 547 | + | ctx: ctx, | |
| 548 | + | activeAudioStream: stream, | |
| 549 | + | } | |
| 550 | + | handlers := &handlerSet{wv: wv} | |
| 551 | + | ||
| 552 | + | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | |
| 553 | + | ||
| 554 | + | // Start a packet write — it will block inside Write. | |
| 555 | + | go handlers.OnAudioStreamPacket(nil, data, 2, 0) | |
| 556 | + | ||
| 557 | + | // Wait for Write to be entered. | |
| 558 | + | <-stream.writeCh | |
| 559 | + | ||
| 560 | + | // Now try to close. If the implementation holds the lock across Write, | |
| 561 | + | // this will block until Write returns. If not, Close races with Write. | |
| 562 | + | closeDone := make(chan struct{}) | |
| 563 | + | go func() { | |
| 564 | + | wv.closeAudioStream() | |
| 565 | + | close(closeDone) | |
| 566 | + | }() | |
| 567 | + | ||
| 568 | + | // Close should NOT have completed yet because Write holds the lock. | |
| 569 | + | // Give the goroutine time to acquire the lock if it can. | |
| 570 | + | time.Sleep(50 * time.Millisecond) | |
| 571 | + | select { | |
| 572 | + | case <-closeDone: | |
| 573 | + | t.Fatal("closeAudioStream completed while Write was in-flight — lock not held across Write") | |
| 574 | + | default: | |
| 575 | + | // expected: closeAudioStream is blocked | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // Unblock Write. | |
| 579 | + | close(stream.doneCh) | |
| 580 | + | ||
| 581 | + | // Now closeAudioStream should complete. | |
| 582 | + | <-closeDone | |
| 583 | + | ||
| 584 | + | assert.True(t, stream.closeCalled) | |
| 585 | + | assert.Nil(t, wv.activeAudioStream) | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | |
| 589 | + | // stream is closed between two OnAudioStreamPacket calls, the second call | |
| 590 | + | // sees nil and silently drops the packet. | |
| 591 | + | func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) { | |
| 592 | + | ctx := context.Background() | |
| 593 | + | stream := &mockAudioStream{} | |
| 594 | + | ||
| 595 | + | wv := &WebView{ | |
| 596 | + | ctx: ctx, | |
| 597 | + | activeAudioStream: stream, | |
| 598 | + | } | |
| 599 | + | handlers := &handlerSet{wv: wv} | |
| 600 | + | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | |
| 601 | + | ||
| 602 | + | // First packet succeeds. | |
| 603 | + | handlers.OnAudioStreamPacket(nil, data, 2, 0) | |
| 604 | + | assert.True(t, stream.writeCalled) | |
| 605 | + | ||
| 606 | + | // Close the stream. | |
| 607 | + | wv.closeAudioStream() | |
| 608 | + | ||
| 609 | + | // Second packet should be silently dropped. | |
| 610 | + | assert.NotPanics(t, func() { | |
| 611 | + | handlers.OnAudioStreamPacket(nil, data, 2, 1) | |
| 612 | + | }) | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // ============================================================================ | |
| 616 | + | // Mock Factories | |
| 617 | + | // ============================================================================ | |
| 618 | + | ||
| 619 | + | // mockAudioFactory is a test double for port.AudioOutputFactory | |
| 620 | + | type mockAudioFactory struct { | |
| 621 | + | stream port.AudioOutputStream | |
| 622 | + | err error | |
| 623 | + | lastFormat *port.AudioStreamFormat | |
| 624 | + | lastCtx context.Context | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | func (m *mockAudioFactory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 628 | + | m.lastCtx = ctx | |
| 629 | + | m.lastFormat = &format | |
| 630 | + | return m.stream, m.err | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // Verify mock implements the interface | |
| 634 | + | var _ port.AudioOutputFactory = (*mockAudioFactory)(nil) | |
M internal/infrastructure/cef/webview_handlers.go +146 -8
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -327,7 +328,8 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||
| 327 | 328 | } | |
| 328 | 329 | ||
| 329 | 330 | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | |
| 330 | - | if h.wv != nil && h.wv.ctx != nil && (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | |
| 331 | + | if h.wv != nil && h.wv.ctx != nil && | |
| 332 | + | (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | |
| 331 | 333 | log := logging.FromContext(h.wv.ctx).With(). | |
| 332 | 334 | Str("component", "cef-console"). | |
| 333 | 335 | Str("source", source). | |
| @@ -460,8 +462,6 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E | |||
| 460 | 462 | // OnBeforePopup intercepts popup requests (target="_blank", window.open). | |
| 461 | 463 | // CEF OSR cannot create popup windows, so we fire the OnCreate callback | |
| 462 | 464 | // to let the coordinator open the link in a new stacked pane. | |
| 463 | - | // | |
| 464 | - | //nolint:gocritic // signature imposed by purecef.LifeSpanHandler interface | |
| 465 | 465 | func (h *handlerSet) OnBeforePopup( | |
| 466 | 466 | _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string, | |
| 467 | 467 | _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | |
| @@ -493,7 +493,6 @@ func (h *handlerSet) OnBeforePopup( | |||
| 493 | 493 | ||
| 494 | 494 | func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {} | |
| 495 | 495 | ||
| 496 | - | //nolint:gocritic // signature imposed by purecef.LifeSpanHandler interface | |
| 497 | 496 | func (h *handlerSet) OnBeforeDevToolsPopup( | |
| 498 | 497 | _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client, | |
| 499 | 498 | _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool, | |
| @@ -600,7 +599,7 @@ func (h *handlerSet) GetResourceRequestHandler( | |||
| 600 | 599 | *disableDefaultHandling = 1 | |
| 601 | 600 | if h.wv != nil && h.wv.ctx != nil { | |
| 602 | 601 | logging.FromContext(h.wv.ctx).Info(). | |
| 603 | - | Str("url", logging.TruncateURL(request.GetURL(), 240)). | |
| 602 | + | Str("url", logging.TruncateURL(request.GetURL(), maxTranscodingURLLength)). | |
| 604 | 603 | Msg("cef: disabled default handling for eager transcode candidate") | |
| 605 | 604 | } | |
| 606 | 605 | } | |
| @@ -688,18 +687,157 @@ func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParam | |||
| 688 | 687 | return 1 // proceed with defaults | |
| 689 | 688 | } | |
| 690 | 689 | ||
| 691 | - | func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, _ *purecef.AudioParameters, _ int32) { | |
| 690 | + | // OnAudioStreamStarted handles the start of an audio stream. | |
| 691 | + | // The third callback argument is the channel count (not frames-per-buffer); | |
| 692 | + | // frames-per-buffer comes from params.FramesPerBuffer. | |
| 693 | + | func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.AudioParameters, channels int32) { | |
| 694 | + | // Validate params and factory first | |
| 695 | + | if params == nil || h.wv.audioOutputFactory == nil { | |
| 696 | + | return | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // Build format from CEF parameters | |
| 700 | + | format := h.buildAudioStreamFormat(params, channels) | |
| 701 | + | ||
| 702 | + | if h.wv.ctx != nil { | |
| 703 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 704 | + | Int("sample_rate", format.SampleRate). | |
| 705 | + | Int("channels", format.ChannelCount). | |
| 706 | + | Int("frames_per_buffer", format.FramesPerBuffer). | |
| 707 | + | Int32("channel_layout", params.ChannelLayout). | |
| 708 | + | Msg("cef: OnAudioStreamStarted") | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | // Reset packet counters for the new stream | |
| 712 | + | h.wv.audioPacketCount.Store(0) | |
| 713 | + | h.wv.audioWriteCount.Store(0) | |
| 714 | + | ||
| 715 | + | // Close any existing stream first | |
| 716 | + | h.wv.closeAudioStream() | |
| 717 | + | ||
| 718 | + | // Create new stream | |
| 719 | + | ctx := h.wv.ctx | |
| 720 | + | if ctx == nil { | |
| 721 | + | ctx = context.Background() | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | |
| 725 | + | if err != nil { | |
| 726 | + | // Log error but don't panic - audio is non-critical. | |
| 727 | + | // Do NOT set audioPlaying — the stream was never created. | |
| 728 | + | if h.wv.ctx != nil { | |
| 729 | + | logging.FromContext(h.wv.ctx).Warn(). | |
| 730 | + | Err(err). | |
| 731 | + | Int("sample_rate", format.SampleRate). | |
| 732 | + | Int("channels", format.ChannelCount). | |
| 733 | + | Msg("cef: failed to create audio output stream") | |
| 734 | + | } | |
| 735 | + | return | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | // Set playing state only after stream creation succeeds | |
| 692 | 739 | h.wv.setAudioPlaying(true) | |
| 740 | + | ||
| 741 | + | if h.wv.ctx != nil { | |
| 742 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 743 | + | Int("sample_rate", format.SampleRate). | |
| 744 | + | Int("channels", format.ChannelCount). | |
| 745 | + | Int("frames_per_buffer", format.FramesPerBuffer). | |
| 746 | + | Msg("cef: audio output stream created successfully") | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | // Store the new stream | |
| 750 | + | h.wv.audioStreamMu.Lock() | |
| 751 | + | h.wv.activeAudioStream = stream | |
| 752 | + | h.wv.audioStreamMu.Unlock() | |
| 693 | 753 | } | |
| 694 | 754 | ||
| 695 | - | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, _ [][]float32, _ int32, _ int64) {} | |
| 755 | + | // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat. | |
| 756 | + | // channels is the authoritative channel count from the callback; params.FramesPerBuffer | |
| 757 | + | // supplies the buffer size. | |
| 758 | + | func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, channels int32) port.AudioStreamFormat { | |
| 759 | + | return port.AudioStreamFormat{ | |
| 760 | + | SampleRate: int(params.SampleRate), | |
| 761 | + | ChannelCount: int(channels), | |
| 762 | + | FramesPerBuffer: int(params.FramesPerBuffer), | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // OnAudioStreamPacket receives audio packets and forwards them to the output stream. | |
| 767 | + | // The mutex is held across both the stream snapshot and Write to prevent | |
| 768 | + | // closeAudioStream from closing the stream mid-write. | |
| 769 | + | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) { | |
| 770 | + | if len(data) == 0 || frames <= 0 { | |
| 771 | + | return | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | // Copy the data before acquiring the stream lock because CEF can reuse the | |
| 775 | + | // buffer as soon as this callback returns. | |
| 776 | + | copiedData := make([][]float32, len(data)) | |
| 777 | + | for i, channel := range data { | |
| 778 | + | if len(channel) < int(frames) { | |
| 779 | + | continue | |
| 780 | + | } | |
| 781 | + | copiedData[i] = make([]float32, frames) | |
| 782 | + | copy(copiedData[i], channel[:frames]) | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | // Hold the lock across the stream read and Write so closeAudioStream | |
| 786 | + | // cannot close the stream between snapshot and write. | |
| 787 | + | h.wv.audioStreamMu.Lock() | |
| 788 | + | stream := h.wv.activeAudioStream | |
| 789 | + | if stream == nil { | |
| 790 | + | h.wv.audioStreamMu.Unlock() | |
| 791 | + | return | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | pktNum := h.wv.audioPacketCount.Add(1) | |
| 795 | + | if pktNum == 1 && h.wv.ctx != nil { | |
| 796 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 797 | + | Int("channels", len(data)). | |
| 798 | + | Int32("frames", frames). | |
| 799 | + | Int64("pts", pts). | |
| 800 | + | Msg("cef: first audio packet received") | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | err := stream.Write(copiedData) | |
| 804 | + | h.wv.audioStreamMu.Unlock() | |
| 696 | 805 | ||
| 806 | + | if err != nil { | |
| 807 | + | if h.wv.ctx != nil { | |
| 808 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 809 | + | Err(err). | |
| 810 | + | Uint64("packets_received", pktNum). | |
| 811 | + | Msg("cef: audio stream write failed") | |
| 812 | + | } | |
| 813 | + | return | |
| 814 | + | } | |
| 815 | + | h.wv.audioWriteCount.Add(1) | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | // OnAudioStreamStopped handles stream stop. | |
| 697 | 819 | func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) { | |
| 820 | + | if h.wv.ctx != nil { | |
| 821 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 822 | + | Uint64("packets_received", h.wv.audioPacketCount.Load()). | |
| 823 | + | Uint64("writes_succeeded", h.wv.audioWriteCount.Load()). | |
| 824 | + | Msg("cef: OnAudioStreamStopped") | |
| 825 | + | } | |
| 698 | 826 | h.wv.setAudioPlaying(false) | |
| 827 | + | h.wv.closeAudioStream() | |
| 699 | 828 | } | |
| 700 | 829 | ||
| 701 | - | func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, _ string) { | |
| 830 | + | // OnAudioStreamError handles stream errors. | |
| 831 | + | func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) { | |
| 832 | + | if h.wv.ctx != nil { | |
| 833 | + | logging.FromContext(h.wv.ctx).Warn(). | |
| 834 | + | Str("error", message). | |
| 835 | + | Uint64("packets_received", h.wv.audioPacketCount.Load()). | |
| 836 | + | Uint64("writes_succeeded", h.wv.audioWriteCount.Load()). | |
| 837 | + | Msg("cef: audio stream error") | |
| 838 | + | } | |
| 702 | 839 | h.wv.setAudioPlaying(false) | |
| 840 | + | h.wv.closeAudioStream() | |
| 703 | 841 | } | |
| 704 | 842 | ||
| 705 | 843 | // =========================================================================== | |
M internal/infrastructure/config/defaults.go +1 -0
| @@ -161,6 +161,7 @@ func DefaultConfig() *Config { | |||
| 161 | 161 | CEF: CEFEngineConfig{ | |
| 162 | 162 | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 163 | 163 | LogFile: "", | |
| 164 | + | EnableAudioHandler: true, | |
| 164 | 165 | }, | |
| 165 | 166 | WebKit: WebKitEngineConfig{ | |
| 166 | 167 | ITPEnabled: true, | |
M internal/infrastructure/config/defaults_test.go +1 -1
| @@ -22,7 +22,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 22 | 22 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | |
| 23 | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | |
| 24 | 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | |
| 25 | - | assert.False(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 25 | + | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 26 | 26 | assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler) | |
| 27 | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | |
| 28 | 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | |
M internal/infrastructure/config/engine.go +12 -12
| @@ -7,6 +7,17 @@ const ( | |||
| 7 | 7 | EngineTypeCEF = "cef" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | // EngineConfig holds engine selection and universal engine options. | |
| 11 | + | type EngineConfig struct { | |
| 12 | + | Type string `mapstructure:"type" toml:"type" yaml:"type"` | |
| 13 | + | PoolPrewarmCount int `mapstructure:"pool_prewarm_count" toml:"pool_prewarm_count" yaml:"pool_prewarm_count"` | |
| 14 | + | ZoomCacheSize int `mapstructure:"zoom_cache_size" toml:"zoom_cache_size" yaml:"zoom_cache_size"` | |
| 15 | + | Profile PerformanceProfile `mapstructure:"profile" toml:"profile" yaml:"profile"` | |
| 16 | + | CookiePolicy CookiePolicy `mapstructure:"cookie_policy" toml:"cookie_policy" yaml:"cookie_policy"` | |
| 17 | + | WebKit WebKitEngineConfig `mapstructure:"webkit" toml:"webkit" yaml:"webkit"` | |
| 18 | + | CEF CEFEngineConfig `mapstructure:"cef" toml:"cef" yaml:"cef"` | |
| 19 | + | } | |
| 20 | + | ||
| 10 | 21 | // ResolveEngineType returns the effective engine type from config + env override. | |
| 11 | 22 | // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE | |
| 12 | 23 | // to override the configured value for smoke testing. | |
| @@ -21,17 +32,6 @@ func (e *EngineConfig) ResolveEngineType() string { | |||
| 21 | 32 | return engineType | |
| 22 | 33 | } | |
| 23 | 34 | ||
| 24 | - | // EngineConfig holds engine selection and universal engine options. | |
| 25 | - | type EngineConfig struct { | |
| 26 | - | Type string `mapstructure:"type" toml:"type" yaml:"type"` | |
| 27 | - | PoolPrewarmCount int `mapstructure:"pool_prewarm_count" toml:"pool_prewarm_count" yaml:"pool_prewarm_count"` | |
| 28 | - | ZoomCacheSize int `mapstructure:"zoom_cache_size" toml:"zoom_cache_size" yaml:"zoom_cache_size"` | |
| 29 | - | Profile PerformanceProfile `mapstructure:"profile" toml:"profile" yaml:"profile"` | |
| 30 | - | CookiePolicy CookiePolicy `mapstructure:"cookie_policy" toml:"cookie_policy" yaml:"cookie_policy"` | |
| 31 | - | WebKit WebKitEngineConfig `mapstructure:"webkit" toml:"webkit" yaml:"webkit"` | |
| 32 | - | CEF CEFEngineConfig `mapstructure:"cef" toml:"cef" yaml:"cef"` | |
| 33 | - | } | |
| 34 | - | ||
| 35 | 35 | // WebKitEngineConfig holds WebKit-specific engine options. | |
| 36 | 36 | type WebKitEngineConfig struct { | |
| 37 | 37 | // Skia rendering threads | |
| @@ -96,7 +96,7 @@ type CEFEngineConfig struct { | |||
| 96 | 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 97 | 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 98 | 98 | // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge. | |
| 99 | - | EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll | |
| 99 | + | EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit | |
| 100 | 100 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | |
| 101 | 101 | TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"` | |
| 102 | 102 | } | |
M internal/infrastructure/config/validation.go +5 -2
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | const cefLogSeverityDisabled = 99 | |
| 14 | + | ||
| 13 | 15 | // validateConfig performs comprehensive validation of configuration values | |
| 14 | 16 | func validateConfig(config *Config) error { | |
| 15 | 17 | var validationErrors []string | |
| @@ -329,10 +331,11 @@ func validateCEF(config *Config) []string { | |||
| 329 | 331 | var validationErrors []string | |
| 330 | 332 | ||
| 331 | 333 | switch config.Engine.CEF.LogSeverity { | |
| 332 | - | case 0, 1, 2, 3, 4, 99: | |
| 334 | + | case 0, 1, 2, 3, 4, cefLogSeverityDisabled: | |
| 333 | 335 | default: | |
| 334 | 336 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 335 | - | "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, 99 (got: %d)", | |
| 337 | + | "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)", | |
| 338 | + | cefLogSeverityDisabled, | |
| 336 | 339 | config.Engine.CEF.LogSeverity, | |
| 337 | 340 | )) | |
| 338 | 341 | } | |
M internal/infrastructure/config/writer_test.go +10 -6
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "strings" | |
| @@ -48,17 +49,20 @@ func TestWriteConfigOrdered(t *testing.T) { | |||
| 48 | 49 | } | |
| 49 | 50 | // Match "enabled = true" specifically within the [transcoding] section to | |
| 50 | 51 | // avoid false positives from other sections that may also have an enabled key. | |
| 51 | - | transcodingIdx := strings.Index(string(content), "[transcoding]") | |
| 52 | - | afterTranscoding := string(content)[transcodingIdx:] | |
| 53 | - | nextSection := strings.Index(afterTranscoding[1:], "\n[") | |
| 54 | - | var transcodingBlock string | |
| 52 | + | transcodingIdx := bytes.Index(content, []byte("[transcoding]")) | |
| 53 | + | if transcodingIdx < 0 { | |
| 54 | + | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | |
| 55 | + | } | |
| 56 | + | afterTranscoding := content[transcodingIdx:] | |
| 57 | + | nextSection := bytes.Index(afterTranscoding[1:], []byte("\n[")) | |
| 58 | + | var transcodingBlock []byte | |
| 55 | 59 | if nextSection >= 0 { | |
| 56 | 60 | transcodingBlock = afterTranscoding[:nextSection+1] | |
| 57 | 61 | } else { | |
| 58 | 62 | transcodingBlock = afterTranscoding | |
| 59 | 63 | } | |
| 60 | - | if !strings.Contains(transcodingBlock, "enabled = true") { | |
| 61 | - | t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", transcodingBlock) | |
| 64 | + | if !bytes.Contains(transcodingBlock, []byte("enabled = true")) { | |
| 65 | + | t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", string(transcodingBlock)) | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 64 | 68 | t.Logf("Found %d sections, all sorted", len(sections)) | |
M internal/infrastructure/filtering/manager_test.go +14 -0
| @@ -51,6 +51,10 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) { | |||
| 51 | 51 | Return(nil, nil) // nil filter triggers fallback to download | |
| 52 | 52 | ||
| 53 | 53 | // Fallback: download will be triggered since Load returned nil filter | |
| 54 | + | mockDownloader.EXPECT(). | |
| 55 | + | GetCachedFilterPaths(). | |
| 56 | + | Return(nil) | |
| 57 | + | ||
| 54 | 58 | mockDownloader.EXPECT(). | |
| 55 | 59 | DownloadFilters(mock.Anything, mock.Anything). | |
| 56 | 60 | Return([]string{jsonFile}, nil) | |
| @@ -140,6 +144,10 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||
| 140 | 144 | Return([]string{}, nil) | |
| 141 | 145 | ||
| 142 | 146 | // Download should be triggered | |
| 147 | + | mockDownloader.EXPECT(). | |
| 148 | + | GetCachedFilterPaths(). | |
| 149 | + | Return(nil) | |
| 150 | + | ||
| 143 | 151 | mockDownloader.EXPECT(). | |
| 144 | 152 | DownloadFilters(mock.Anything, mock.Anything). | |
| 145 | 153 | Return([]string{jsonFile}, nil) | |
| @@ -455,6 +463,9 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||
| 455 | 463 | mockStore.EXPECT(). | |
| 456 | 464 | Load(mock.Anything, "ublock-combined-0"). | |
| 457 | 465 | Return(nil, nil) // nil triggers download fallback | |
| 466 | + | mockDownloader.EXPECT(). | |
| 467 | + | GetCachedFilterPaths(). | |
| 468 | + | Return(nil) | |
| 458 | 469 | mockDownloader.EXPECT(). | |
| 459 | 470 | DownloadFilters(mock.Anything, mock.Anything). | |
| 460 | 471 | Return([]string{jsonFile}, nil) | |
| @@ -519,6 +530,9 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) { | |||
| 519 | 530 | mockStore.EXPECT(). | |
| 520 | 531 | FetchIdentifiers(mock.Anything). | |
| 521 | 532 | Return([]string{}, nil) | |
| 533 | + | mockDownloader.EXPECT(). | |
| 534 | + | GetCachedFilterPaths(). | |
| 535 | + | Return(nil) | |
| 522 | 536 | ||
| 523 | 537 | mockDownloader.EXPECT(). | |
| 524 | 538 | DownloadFilters(mock.Anything, mock.Anything). | |
M internal/infrastructure/transcoder/audio.go +88 -73
| @@ -20,6 +20,35 @@ const opusBitrate = 128000 | |||
| 20 | 20 | // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration. | |
| 21 | 21 | const opusFrameSize = 960 | |
| 22 | 22 | ||
| 23 | + | func freeCodecContext(ctx *unsafe.Pointer) { | |
| 24 | + | //nolint:gosec // FFmpeg expects AVCodecContext** for ownership release. | |
| 25 | + | ffmpeg.CodecFreeContext(unsafe.Pointer(ctx)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func freeFrame(frame *unsafe.Pointer) { | |
| 29 | + | //nolint:gosec // FFmpeg expects AVFrame** for ownership release. | |
| 30 | + | ffmpeg.FrameFree(unsafe.Pointer(frame)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func freeSwrContext(swr *unsafe.Pointer) { | |
| 34 | + | //nolint:gosec // FFmpeg expects SwrContext** for ownership release. | |
| 35 | + | ffmpeg.SwrFree(unsafe.Pointer(swr)) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func freePacket(pkt *unsafe.Pointer) { | |
| 39 | + | //nolint:gosec // FFmpeg expects AVPacket** for ownership release. | |
| 40 | + | ffmpeg.PacketFree(unsafe.Pointer(pkt)) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func swrConvert(swr, outFrame, inFrame unsafe.Pointer, outSamples, inSamples int32) int32 { | |
| 44 | + | //nolint:gosec // FFmpeg expects raw AVFrame data pointers here. | |
| 45 | + | return ffmpeg.SwrConvert( | |
| 46 | + | swr, | |
| 47 | + | unsafe.Pointer((*unsafe.Pointer)(outFrame)), outSamples, | |
| 48 | + | unsafe.Pointer((*unsafe.Pointer)(inFrame)), inSamples, | |
| 49 | + | ) | |
| 50 | + | } | |
| 51 | + | ||
| 23 | 52 | // audioTranscoder decodes an input audio stream (typically AAC) and | |
| 24 | 53 | // re-encodes it to Opus, performing sample rate conversion if needed. | |
| 25 | 54 | type audioTranscoder struct { | |
| @@ -38,6 +67,11 @@ type audioTranscoder struct { | |||
| 38 | 67 | // outFmtCtx is the AVFormatContext* for the output container (used to | |
| 39 | 68 | // add the output stream). inStreamIdx is the input stream index. | |
| 40 | 69 | func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |
| 70 | + | return newAudioTranscoderImpl(inCodecPar, outFmtCtx, inStreamIdx) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | //nolint:gocyclo,funlen // FFmpeg decoder/encoder/resampler setup needs the necessary branches. | |
| 74 | + | func newAudioTranscoderImpl(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |
| 41 | 75 | // --- Decoder setup --- | |
| 42 | 76 | codecID := ffmpeg.CodecParCodecID(inCodecPar) | |
| 43 | 77 | dec := ffmpeg.CodecFindDecoder(codecID) | |
| @@ -51,25 +85,25 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 51 | 85 | } | |
| 52 | 86 | ||
| 53 | 87 | if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 { | |
| 54 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 88 | + | freeCodecContext(&decCtx) | |
| 55 | 89 | return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret) | |
| 56 | 90 | } | |
| 57 | 91 | ||
| 58 | 92 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |
| 59 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 93 | + | freeCodecContext(&decCtx) | |
| 60 | 94 | return nil, fmt.Errorf("failed to open audio decoder: %d", ret) | |
| 61 | 95 | } | |
| 62 | 96 | ||
| 63 | 97 | // --- Encoder setup (libopus) --- | |
| 64 | 98 | enc := ffmpeg.CodecFindEncoderByName("libopus") | |
| 65 | 99 | if enc == nil { | |
| 66 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 100 | + | freeCodecContext(&decCtx) | |
| 67 | 101 | return nil, errors.New("libopus encoder not found") | |
| 68 | 102 | } | |
| 69 | 103 | ||
| 70 | 104 | encCtx := ffmpeg.CodecAllocContext3(enc) | |
| 71 | 105 | if encCtx == nil { | |
| 72 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 106 | + | freeCodecContext(&decCtx) | |
| 73 | 107 | return nil, errors.New("failed to allocate audio encoder context") | |
| 74 | 108 | } | |
| 75 | 109 | ||
| @@ -88,16 +122,16 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 88 | 122 | // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus. | |
| 89 | 123 | ||
| 90 | 124 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |
| 91 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 92 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 125 | + | freeCodecContext(&encCtx) | |
| 126 | + | freeCodecContext(&decCtx) | |
| 93 | 127 | return nil, fmt.Errorf("failed to open audio encoder: %d", ret) | |
| 94 | 128 | } | |
| 95 | 129 | ||
| 96 | 130 | // --- Add output stream --- | |
| 97 | 131 | outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil) | |
| 98 | 132 | if outStreamPtr == nil { | |
| 99 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 100 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 133 | + | freeCodecContext(&encCtx) | |
| 134 | + | freeCodecContext(&decCtx) | |
| 101 | 135 | return nil, errors.New("failed to create output audio stream") | |
| 102 | 136 | } | |
| 103 | 137 | ||
| @@ -106,8 +140,8 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 106 | 140 | ||
| 107 | 141 | // Copy encoder parameters to the output stream's codecpar. | |
| 108 | 142 | if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 { | |
| 109 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 110 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 143 | + | freeCodecContext(&encCtx) | |
| 144 | + | freeCodecContext(&decCtx) | |
| 111 | 145 | return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret) | |
| 112 | 146 | } | |
| 113 | 147 | ||
| @@ -122,8 +156,8 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 122 | 156 | if needResample { | |
| 123 | 157 | swr = ffmpeg.SwrAlloc() | |
| 124 | 158 | if swr == nil { | |
| 125 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 126 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 159 | + | freeCodecContext(&encCtx) | |
| 160 | + | freeCodecContext(&decCtx) | |
| 127 | 161 | return nil, errors.New("failed to allocate resampler") | |
| 128 | 162 | } | |
| 129 | 163 | ||
| @@ -142,18 +176,18 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 142 | 176 | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | |
| 143 | 177 | ||
| 144 | 178 | if ret := ffmpeg.SwrInit(swr); ret < 0 { | |
| 145 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | |
| 146 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 147 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 179 | + | freeSwrContext(&swr) | |
| 180 | + | freeCodecContext(&encCtx) | |
| 181 | + | freeCodecContext(&decCtx) | |
| 148 | 182 | return nil, fmt.Errorf("failed to init resampler: %d", ret) | |
| 149 | 183 | } | |
| 150 | 184 | ||
| 151 | 185 | // Allocate a reusable output frame for resampled data. | |
| 152 | 186 | resampledFrame = ffmpeg.FrameAlloc() | |
| 153 | 187 | if resampledFrame == nil { | |
| 154 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | |
| 155 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 156 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 188 | + | freeSwrContext(&swr) | |
| 189 | + | freeCodecContext(&encCtx) | |
| 190 | + | freeCodecContext(&decCtx) | |
| 157 | 191 | return nil, errors.New("failed to allocate resampled frame") | |
| 158 | 192 | } | |
| 159 | 193 | ||
| @@ -167,27 +201,27 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 167 | 201 | decFrame := ffmpeg.FrameAlloc() | |
| 168 | 202 | if decFrame == nil { | |
| 169 | 203 | if resampledFrame != nil { | |
| 170 | - | ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame)) | |
| 204 | + | freeFrame(&resampledFrame) | |
| 171 | 205 | } | |
| 172 | 206 | if swr != nil { | |
| 173 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | |
| 207 | + | freeSwrContext(&swr) | |
| 174 | 208 | } | |
| 175 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 176 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 209 | + | freeCodecContext(&encCtx) | |
| 210 | + | freeCodecContext(&decCtx) | |
| 177 | 211 | return nil, errors.New("failed to allocate audio decode frame") | |
| 178 | 212 | } | |
| 179 | 213 | ||
| 180 | 214 | encPkt := ffmpeg.PacketAlloc() | |
| 181 | 215 | if encPkt == nil { | |
| 182 | - | ffmpeg.FrameFree(unsafe.Pointer(&decFrame)) | |
| 216 | + | freeFrame(&decFrame) | |
| 183 | 217 | if resampledFrame != nil { | |
| 184 | - | ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame)) | |
| 218 | + | freeFrame(&resampledFrame) | |
| 185 | 219 | } | |
| 186 | 220 | if swr != nil { | |
| 187 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | |
| 221 | + | freeSwrContext(&swr) | |
| 188 | 222 | } | |
| 189 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 190 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |
| 223 | + | freeCodecContext(&encCtx) | |
| 224 | + | freeCodecContext(&decCtx) | |
| 191 | 225 | return nil, errors.New("failed to allocate audio encode packet") | |
| 192 | 226 | } | |
| 193 | 227 | ||
| @@ -207,17 +241,17 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||
| 207 | 241 | // to Opus, and writes the resulting packets to outFmtCtx. | |
| 208 | 242 | func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |
| 209 | 243 | // Send packet to decoder. | |
| 210 | - | ret := ffmpeg.CodecSendPacket(a.decoder, pkt) | |
| 211 | - | if ret < 0 { | |
| 212 | - | return fmt.Errorf("audio decode send packet: %d", ret) | |
| 244 | + | sendRet := ffmpeg.CodecSendPacket(a.decoder, pkt) | |
| 245 | + | if sendRet < 0 { | |
| 246 | + | return fmt.Errorf("audio decode send packet: %d", sendRet) | |
| 213 | 247 | } | |
| 214 | 248 | ||
| 215 | 249 | frame := a.decFrame | |
| 216 | 250 | encPkt := a.encPkt | |
| 217 | 251 | ||
| 218 | 252 | for { | |
| 219 | - | ret = ffmpeg.CodecReceiveFrame(a.decoder, frame) | |
| 220 | - | if ret < 0 { | |
| 253 | + | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |
| 254 | + | if recvRet < 0 { | |
| 221 | 255 | // EAGAIN or EOF — no more frames from this packet. | |
| 222 | 256 | break | |
| 223 | 257 | } | |
| @@ -227,27 +261,16 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 227 | 261 | if a.resampler != nil { | |
| 228 | 262 | encFrame = a.frame | |
| 229 | 263 | ||
| 230 | - | // AVFrame.data[0] is the first field at offset 0, so casting the | |
| 231 | - | // frame pointer to *unsafe.Pointer yields &data[0]. This is the | |
| 232 | - | // standard FFmpeg pattern for passing data pointers to swr_convert. | |
| 233 | - | // purego-ffmpeg does not expose typed accessors for the data array, | |
| 234 | - | // so raw pointer arithmetic is the only option. | |
| 235 | - | inData := (*unsafe.Pointer)(frame) | |
| 236 | - | outData := (*unsafe.Pointer)(a.frame) | |
| 237 | 264 | inSamples := frameNbSamples(frame) | |
| 238 | 265 | ||
| 239 | 266 | // Allocate buffer for output frame if needed. | |
| 240 | 267 | frameSetNbSamples(a.frame, opusFrameSize) | |
| 241 | - | if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 { | |
| 268 | + | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |
| 242 | 269 | ffmpeg.FrameUnref(frame) | |
| 243 | - | return fmt.Errorf("failed to allocate resampled frame buffer: %d", ret) | |
| 270 | + | return fmt.Errorf("failed to allocate resampled frame buffer: %d", bufferRet) | |
| 244 | 271 | } | |
| 245 | 272 | ||
| 246 | - | converted := ffmpeg.SwrConvert( | |
| 247 | - | a.resampler, | |
| 248 | - | unsafe.Pointer(outData), opusFrameSize, | |
| 249 | - | unsafe.Pointer(inData), inSamples, | |
| 250 | - | ) | |
| 273 | + | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |
| 251 | 274 | if converted < 0 { | |
| 252 | 275 | ffmpeg.FrameUnref(frame) | |
| 253 | 276 | return fmt.Errorf("audio resample failed: %d", converted) | |
| @@ -264,15 +287,15 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 264 | 287 | } | |
| 265 | 288 | ||
| 266 | 289 | // Send frame to encoder. | |
| 267 | - | if ret := ffmpeg.CodecSendFrame(a.encoder, encFrame); ret < 0 { | |
| 290 | + | if sendFrameRet := ffmpeg.CodecSendFrame(a.encoder, encFrame); sendFrameRet < 0 { | |
| 268 | 291 | ffmpeg.FrameUnref(frame) | |
| 269 | - | return fmt.Errorf("audio encode send frame: %d", ret) | |
| 292 | + | return fmt.Errorf("audio encode send frame: %d", sendFrameRet) | |
| 270 | 293 | } | |
| 271 | 294 | ||
| 272 | 295 | // Receive encoded packets. | |
| 273 | 296 | for { | |
| 274 | - | ret = ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 275 | - | if ret < 0 { | |
| 297 | + | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 298 | + | if recvPktRet < 0 { | |
| 276 | 299 | break | |
| 277 | 300 | } | |
| 278 | 301 | ||
| @@ -309,32 +332,24 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 309 | 332 | ||
| 310 | 333 | // Drain decoded frames. | |
| 311 | 334 | for { | |
| 312 | - | ret := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |
| 313 | - | if ret < 0 { | |
| 335 | + | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |
| 336 | + | if recvRet < 0 { | |
| 314 | 337 | break | |
| 315 | 338 | } | |
| 316 | 339 | ||
| 317 | 340 | encFrame := frame | |
| 318 | 341 | if a.resampler != nil { | |
| 319 | 342 | encFrame = a.frame | |
| 320 | - | // See processPacket for why these casts are safe: AVFrame.data[0] | |
| 321 | - | // is at offset 0, so the frame pointer itself is &data[0]. | |
| 322 | - | inData := (*unsafe.Pointer)(frame) | |
| 323 | - | outData := (*unsafe.Pointer)(a.frame) | |
| 324 | 343 | inSamples := frameNbSamples(frame) | |
| 325 | 344 | ||
| 326 | 345 | frameSetNbSamples(a.frame, opusFrameSize) | |
| 327 | - | if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 { | |
| 328 | - | logger.Warn().Int32("ret", ret).Msg("failed to allocate resampled frame buffer during flush") | |
| 346 | + | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |
| 347 | + | logger.Warn().Int32("ret", bufferRet).Msg("failed to allocate resampled frame buffer during flush") | |
| 329 | 348 | ffmpeg.FrameUnref(frame) | |
| 330 | 349 | continue | |
| 331 | 350 | } | |
| 332 | 351 | ||
| 333 | - | converted := ffmpeg.SwrConvert( | |
| 334 | - | a.resampler, | |
| 335 | - | unsafe.Pointer(outData), opusFrameSize, | |
| 336 | - | unsafe.Pointer(inData), inSamples, | |
| 337 | - | ) | |
| 352 | + | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |
| 338 | 353 | if converted < 0 { | |
| 339 | 354 | logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush") | |
| 340 | 355 | ffmpeg.FrameUnref(frame) | |
| @@ -346,8 +361,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 346 | 361 | ffmpeg.CodecSendFrame(a.encoder, encFrame) | |
| 347 | 362 | ||
| 348 | 363 | for { | |
| 349 | - | ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 350 | - | if ret < 0 { | |
| 364 | + | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 365 | + | if recvPktRet < 0 { | |
| 351 | 366 | break | |
| 352 | 367 | } | |
| 353 | 368 | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| @@ -364,8 +379,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 364 | 379 | // Flush encoder by sending NULL frame. | |
| 365 | 380 | ffmpeg.CodecSendFrame(a.encoder, nil) | |
| 366 | 381 | for { | |
| 367 | - | ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 368 | - | if ret < 0 { | |
| 382 | + | flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |
| 383 | + | if flushPktRet < 0 { | |
| 369 | 384 | break | |
| 370 | 385 | } | |
| 371 | 386 | pktSetStreamIndex(encPkt, int32(a.outStream)) | |
| @@ -380,21 +395,21 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||
| 380 | 395 | // close frees all FFmpeg resources owned by the audio transcoder. | |
| 381 | 396 | func (a *audioTranscoder) close() { | |
| 382 | 397 | if a.encPkt != nil { | |
| 383 | - | ffmpeg.PacketFree(unsafe.Pointer(&a.encPkt)) | |
| 398 | + | freePacket(&a.encPkt) | |
| 384 | 399 | } | |
| 385 | 400 | if a.decFrame != nil { | |
| 386 | - | ffmpeg.FrameFree(unsafe.Pointer(&a.decFrame)) | |
| 401 | + | freeFrame(&a.decFrame) | |
| 387 | 402 | } | |
| 388 | 403 | if a.frame != nil { | |
| 389 | - | ffmpeg.FrameFree(unsafe.Pointer(&a.frame)) | |
| 404 | + | freeFrame(&a.frame) | |
| 390 | 405 | } | |
| 391 | 406 | if a.resampler != nil { | |
| 392 | - | ffmpeg.SwrFree(unsafe.Pointer(&a.resampler)) | |
| 407 | + | freeSwrContext(&a.resampler) | |
| 393 | 408 | } | |
| 394 | 409 | if a.encoder != nil { | |
| 395 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&a.encoder)) | |
| 410 | + | freeCodecContext(&a.encoder) | |
| 396 | 411 | } | |
| 397 | 412 | if a.decoder != nil { | |
| 398 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&a.decoder)) | |
| 413 | + | freeCodecContext(&a.decoder) | |
| 399 | 414 | } | |
| 400 | 415 | } | |
M internal/infrastructure/transcoder/detect.go +4 -1
| @@ -113,7 +113,10 @@ func IsEagerTranscodeURL(rawURL string) bool { | |||
| 113 | 113 | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | |
| 114 | 114 | // that should be intercepted and resolved to an underlying source URL. | |
| 115 | 115 | func IsSyntheticTranscodeURL(rawURL string) bool { | |
| 116 | - | _, _, _, ok := ParseSyntheticTranscodeURL(rawURL) | |
| 116 | + | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |
| 117 | + | _ = sourceURL | |
| 118 | + | _ = referer | |
| 119 | + | _ = origin | |
| 117 | 120 | return ok | |
| 118 | 121 | } | |
| 119 | 122 | ||
M internal/infrastructure/transcoder/ffmpeg_offsets.go +11 -48
| @@ -20,8 +20,6 @@ import "unsafe" | |||
| 20 | 20 | // --------------------------------------------------------------------------- | |
| 21 | 21 | ||
| 22 | 22 | const ( | |
| 23 | - | offsetPktPts = 8 | |
| 24 | - | offsetPktDts = 16 | |
| 25 | 23 | offsetPktStreamIndex = 36 | |
| 26 | 24 | ) | |
| 27 | 25 | ||
| @@ -91,7 +89,7 @@ func frameSetFormat(f unsafe.Pointer, v int32) { | |||
| 91 | 89 | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | |
| 92 | 90 | } | |
| 93 | 91 | ||
| 94 | - | func frameSetHWFramesCtx(f unsafe.Pointer, v unsafe.Pointer) { | |
| 92 | + | func frameSetHWFramesCtx(f, v unsafe.Pointer) { | |
| 95 | 93 | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | |
| 96 | 94 | } | |
| 97 | 95 | ||
| @@ -103,10 +101,6 @@ func frameSetPts(f unsafe.Pointer, v int64) { | |||
| 103 | 101 | *(*int64)(unsafe.Add(f, offsetFramePts)) = v | |
| 104 | 102 | } | |
| 105 | 103 | ||
| 106 | - | func frameSampleRate(f unsafe.Pointer) int32 { | |
| 107 | - | return *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | 104 | func frameSetSampleRate(f unsafe.Pointer, v int32) { | |
| 111 | 105 | *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v | |
| 112 | 106 | } | |
| @@ -116,24 +110,11 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||
| 116 | 110 | // --------------------------------------------------------------------------- | |
| 117 | 111 | ||
| 118 | 112 | const ( | |
| 119 | - | offsetCodecCtxFlags = 64 | |
| 120 | 113 | offsetCodecCtxGopSize = 332 | |
| 121 | 114 | offsetCodecCtxBitRate = 56 | |
| 122 | 115 | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) | |
| 123 | 116 | ) | |
| 124 | 117 | ||
| 125 | - | // AV_CODEC_FLAG_GLOBAL_HEADER indicates the codec uses global headers | |
| 126 | - | // that should be stored in extradata instead of every keyframe. | |
| 127 | - | const avCodecFlagGlobalHeader = 1 << 22 | |
| 128 | - | ||
| 129 | - | func codecCtxFlags(ctx unsafe.Pointer) int32 { | |
| 130 | - | return *(*int32)(unsafe.Add(ctx, offsetCodecCtxFlags)) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | func codecCtxSetFlags(ctx unsafe.Pointer, v int32) { | |
| 134 | - | *(*int32)(unsafe.Add(ctx, offsetCodecCtxFlags)) = v | |
| 135 | - | } | |
| 136 | - | ||
| 137 | 118 | func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) { | |
| 138 | 119 | *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v | |
| 139 | 120 | } | |
| @@ -144,13 +125,15 @@ func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |||
| 144 | 125 | ||
| 145 | 126 | // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst. | |
| 146 | 127 | // Both must point to the ch_layout field within their respective AVCodecContext. | |
| 128 | + | // | |
| 129 | + | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |
| 147 | 130 | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |
| 148 | 131 | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | |
| 149 | 132 | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | |
| 150 | 133 | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | |
| 151 | 134 | copy( | |
| 152 | - | unsafe.Slice((*byte)(dstLayout), 24), | |
| 153 | - | unsafe.Slice((*byte)(srcLayout), 24), | |
| 135 | + | unsafe.Slice((*byte)(dstLayout), channelLayoutSize), | |
| 136 | + | unsafe.Slice((*byte)(srcLayout), channelLayoutSize), | |
| 154 | 137 | ) | |
| 155 | 138 | } | |
| 156 | 139 | ||
| @@ -161,41 +144,21 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||
| 161 | 144 | // --------------------------------------------------------------------------- | |
| 162 | 145 | ||
| 163 | 146 | const ( | |
| 147 | + | channelLayoutSize = 24 | |
| 148 | + | ||
| 164 | 149 | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | |
| 165 | 150 | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | |
| 166 | 151 | ) | |
| 167 | 152 | ||
| 168 | 153 | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | |
| 169 | 154 | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | |
| 155 | + | // | |
| 156 | + | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |
| 170 | 157 | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | |
| 171 | 158 | dst := unsafe.Add(swr, swrOffset) | |
| 172 | 159 | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |
| 173 | 160 | copy( | |
| 174 | - | unsafe.Slice((*byte)(dst), 24), | |
| 175 | - | unsafe.Slice((*byte)(src), 24), | |
| 176 | - | ) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // --------------------------------------------------------------------------- | |
| 180 | - | // AVCodecParameters additional offsets (FFmpeg 8.x, libavcodec 62) | |
| 181 | - | // --------------------------------------------------------------------------- | |
| 182 | - | ||
| 183 | - | const ( | |
| 184 | - | offsetCodecParSampleRate = 152 | |
| 185 | - | offsetCodecParChLayout = 128 // AVChannelLayout (24 bytes) | |
| 186 | - | ) | |
| 187 | - | ||
| 188 | - | func codecParSampleRate(par unsafe.Pointer) int32 { | |
| 189 | - | return *(*int32)(unsafe.Add(par, offsetCodecParSampleRate)) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // codecParCopyChLayoutTo copies the ch_layout from AVCodecParameters to | |
| 193 | - | // an AVCodecContext. | |
| 194 | - | func codecParCopyChLayoutTo(codecCtx, par unsafe.Pointer) { | |
| 195 | - | dstLayout := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |
| 196 | - | srcLayout := unsafe.Add(par, offsetCodecParChLayout) | |
| 197 | - | copy( | |
| 198 | - | unsafe.Slice((*byte)(dstLayout), 24), | |
| 199 | - | unsafe.Slice((*byte)(srcLayout), 24), | |
| 161 | + | unsafe.Slice((*byte)(dst), channelLayoutSize), | |
| 162 | + | unsafe.Slice((*byte)(src), channelLayoutSize), | |
| 200 | 163 | ) | |
| 201 | 164 | } | |
M internal/infrastructure/transcoder/hwprobe.go +5 -3
| @@ -16,6 +16,8 @@ var usableEncoders = map[string]bool{ | |||
| 16 | 16 | "av1_nvenc": true, | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | const hwAPINameVAAPI = "vaapi" | |
| 20 | + | ||
| 19 | 21 | // isUsableEncoder returns true only for hardware encoders whose output | |
| 20 | 22 | // codec is open and decodable by CEF (AV1, VP9). | |
| 21 | 23 | func isUsableEncoder(name string) bool { | |
| @@ -31,7 +33,7 @@ type hwProfile struct { | |||
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | var vaAPIProfile = hwProfile{ | |
| 34 | - | api: "vaapi", | |
| 36 | + | api: hwAPINameVAAPI, | |
| 35 | 37 | deviceType: int32(ffmpeg.HwdeviceTypeVaapi), | |
| 36 | 38 | encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"}, | |
| 37 | 39 | decoders: []string{"h264_vaapi", "hevc_vaapi"}, | |
| @@ -59,11 +61,11 @@ func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities { | |||
| 59 | 61 | ||
| 60 | 62 | var profiles []hwProfile | |
| 61 | 63 | switch hwaccelPref { | |
| 62 | - | case "vaapi": | |
| 64 | + | case hwAPINameVAAPI: | |
| 63 | 65 | profiles = []hwProfile{vaAPIProfile} | |
| 64 | 66 | case "nvenc": | |
| 65 | 67 | profiles = []hwProfile{nvencProfile} | |
| 66 | - | default: // "auto" or unrecognised | |
| 68 | + | default: // "auto" or unrecognized | |
| 67 | 69 | profiles = []hwProfile{vaAPIProfile, nvencProfile} | |
| 68 | 70 | } | |
| 69 | 71 | ||
M internal/infrastructure/transcoder/pipeline.go +78 -32
| @@ -24,6 +24,11 @@ import ( | |||
| 24 | 24 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | |
| 25 | 25 | const avioBufSize = 32 * 1024 | |
| 26 | 26 | ||
| 27 | + | const ( | |
| 28 | + | vaapiInitialPoolSize = 20 | |
| 29 | + | minVideoBitrate = 500_000 | |
| 30 | + | ) | |
| 31 | + | ||
| 27 | 32 | // pipeline orchestrates a single GPU transcode session. It reads from | |
| 28 | 33 | // an HTTP source, decodes with a hardware decoder (or software fallback), | |
| 29 | 34 | // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to | |
| @@ -36,7 +41,6 @@ type pipeline struct { | |||
| 36 | 41 | headers map[string]string | |
| 37 | 42 | quality string | |
| 38 | 43 | pw *io.PipeWriter | |
| 39 | - | cancel context.CancelFunc | |
| 40 | 44 | logger zerolog.Logger | |
| 41 | 45 | ||
| 42 | 46 | // Prevent GC of callback closures and uintptrs. | |
| @@ -55,7 +59,15 @@ var httpClient = &http.Client{ | |||
| 55 | 59 | }, | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 58 | - | func newPipeline(sessionID string, hwCaps port.HWCapabilities, sourceURL string, headers map[string]string, quality string, pw *io.PipeWriter, logger zerolog.Logger) *pipeline { | |
| 62 | + | func newPipeline( | |
| 63 | + | sessionID string, | |
| 64 | + | hwCaps port.HWCapabilities, | |
| 65 | + | sourceURL string, | |
| 66 | + | headers map[string]string, | |
| 67 | + | quality string, | |
| 68 | + | pw *io.PipeWriter, | |
| 69 | + | logger zerolog.Logger, | |
| 70 | + | ) *pipeline { | |
| 59 | 71 | return &pipeline{ | |
| 60 | 72 | sessionID: sessionID, | |
| 61 | 73 | hwCaps: hwCaps, | |
| @@ -77,23 +89,28 @@ func (p *pipeline) run(ctx context.Context) { | |||
| 77 | 89 | Str("source_url", p.sourceURL). | |
| 78 | 90 | Logger() | |
| 79 | 91 | if errors.Is(err, context.Canceled) { | |
| 80 | - | log.Debug().Err(err).Msg("transcode pipeline cancelled") | |
| 92 | + | log.Debug().Err(err).Msg("transcode pipeline canceled") | |
| 81 | 93 | } else { | |
| 82 | 94 | log.Error().Err(err).Msg("transcode pipeline failed") | |
| 83 | 95 | } | |
| 84 | - | p.pw.CloseWithError(err) | |
| 96 | + | _ = p.pw.CloseWithError(err) | |
| 85 | 97 | } else { | |
| 86 | 98 | p.logger.Debug(). | |
| 87 | 99 | Str("session_id", p.sessionID). | |
| 88 | 100 | Str("source_url", p.sourceURL). | |
| 89 | 101 | Msg("transcode pipeline completed") | |
| 90 | - | p.pw.Close() | |
| 102 | + | _ = p.pw.Close() | |
| 91 | 103 | } | |
| 92 | 104 | } | |
| 93 | 105 | ||
| 94 | 106 | // doRun contains the actual pipeline logic. Errors are returned, and | |
| 95 | 107 | // the caller (run) closes the pipe writer accordingly. | |
| 96 | 108 | func (p *pipeline) doRun(ctx context.Context) error { | |
| 109 | + | return p.doRunImpl(ctx) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | //nolint:gocyclo,funlen,gosec // FFmpeg pipeline setup/teardown uses necessary branching and unsafe C-API calls. | |
| 113 | + | func (p *pipeline) doRunImpl(ctx context.Context) error { | |
| 97 | 114 | // --- Step 1-3: Open the input using the appropriate strategy --- | |
| 98 | 115 | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | |
| 99 | 116 | if err != nil { | |
| @@ -121,6 +138,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 121 | 138 | if err != nil { | |
| 122 | 139 | return fmt.Errorf("open video decoder: %w", err) | |
| 123 | 140 | } | |
| 141 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 124 | 142 | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx)) | |
| 125 | 143 | ||
| 126 | 144 | // --- Step 7-8: Open video encoder with HW accel --- | |
| @@ -131,10 +149,12 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 131 | 149 | if hwDeviceCtx != nil { | |
| 132 | 150 | defer ffmpeg.BufferUnref(&hwDeviceCtx) | |
| 133 | 151 | } | |
| 152 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 134 | 153 | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx)) | |
| 135 | 154 | ||
| 136 | 155 | // --- Step 9: Create output format context for WebM --- | |
| 137 | 156 | var outFmtCtx unsafe.Pointer | |
| 157 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts. | |
| 138 | 158 | if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 { | |
| 139 | 159 | return fmt.Errorf("alloc output context: %d", ret) | |
| 140 | 160 | } | |
| @@ -146,6 +166,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 146 | 166 | if bufSize <= 0 { | |
| 147 | 167 | return 0 | |
| 148 | 168 | } | |
| 169 | + | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |
| 149 | 170 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 150 | 171 | n, writeErr := pw.Write(goBuf) | |
| 151 | 172 | if writeErr != nil { | |
| @@ -159,6 +180,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 159 | 180 | if writeAVIO == nil { | |
| 160 | 181 | return errors.New("failed to allocate write AVIO context") | |
| 161 | 182 | } | |
| 183 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 162 | 184 | defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO)) | |
| 163 | 185 | ||
| 164 | 186 | ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO) | |
| @@ -184,7 +206,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 184 | 206 | audioCodecPar := audioStreamWrap.Codecpar() | |
| 185 | 207 | ||
| 186 | 208 | var txErr error | |
| 187 | - | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, int(audioIdx)) | |
| 209 | + | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx) | |
| 188 | 210 | if txErr != nil { | |
| 189 | 211 | // Audio transcoding failure is non-fatal; we proceed with video only. | |
| 190 | 212 | audioTx = nil | |
| @@ -210,18 +232,21 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 210 | 232 | if pkt == nil { | |
| 211 | 233 | return errors.New("failed to allocate read packet") | |
| 212 | 234 | } | |
| 235 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |
| 213 | 236 | defer ffmpeg.PacketFree(unsafe.Pointer(&pkt)) | |
| 214 | 237 | ||
| 215 | 238 | frame := ffmpeg.FrameAlloc() | |
| 216 | 239 | if frame == nil { | |
| 217 | 240 | return errors.New("failed to allocate decode frame") | |
| 218 | 241 | } | |
| 242 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |
| 219 | 243 | defer ffmpeg.FrameFree(unsafe.Pointer(&frame)) | |
| 220 | 244 | ||
| 221 | 245 | encPkt := ffmpeg.PacketAlloc() | |
| 222 | 246 | if encPkt == nil { | |
| 223 | 247 | return errors.New("failed to allocate encode packet") | |
| 224 | 248 | } | |
| 249 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |
| 225 | 250 | defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt)) | |
| 226 | 251 | ||
| 227 | 252 | for { | |
| @@ -242,7 +267,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 242 | 267 | ||
| 243 | 268 | switch { | |
| 244 | 269 | case streamIdx == videoIdx: | |
| 245 | - | if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, videoStreamWrap, outVideoIdx); err != nil { | |
| 270 | + | if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |
| 246 | 271 | ffmpeg.PacketUnref(pkt) | |
| 247 | 272 | return fmt.Errorf("video transcode: %w", err) | |
| 248 | 273 | } | |
| @@ -273,7 +298,10 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 273 | 298 | if ret < 0 { | |
| 274 | 299 | break | |
| 275 | 300 | } | |
| 276 | - | p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, videoStreamWrap, outVideoIdx) | |
| 301 | + | if err := p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |
| 302 | + | ffmpeg.FrameUnref(frame) | |
| 303 | + | return fmt.Errorf("video flush: %w", err) | |
| 304 | + | } | |
| 277 | 305 | ffmpeg.FrameUnref(frame) | |
| 278 | 306 | } | |
| 279 | 307 | ||
| @@ -305,7 +333,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||
| 305 | 333 | return nil | |
| 306 | 334 | } | |
| 307 | 335 | ||
| 308 | - | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, audioIdx int, err error) { | |
| 336 | + | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioIdx int, err error) { | |
| 309 | 337 | videoIdx = -1 | |
| 310 | 338 | audioIdx = -1 | |
| 311 | 339 | bestVideoPixels := int64(-1) | |
| @@ -323,7 +351,7 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, au | |||
| 323 | 351 | } | |
| 324 | 352 | ||
| 325 | 353 | switch ffmpeg.CodecParCodecType(codecPar) { | |
| 326 | - | case int32(ffmpeg.AVMEDIA_TYPE_VIDEO): | |
| 354 | + | case ffmpeg.AVMEDIA_TYPE_VIDEO: | |
| 327 | 355 | width := int64(ffmpeg.CodecParWidth(codecPar)) | |
| 328 | 356 | height := int64(ffmpeg.CodecParHeight(codecPar)) | |
| 329 | 357 | pixels := width * height | |
| @@ -331,7 +359,7 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, au | |||
| 331 | 359 | videoIdx = i | |
| 332 | 360 | bestVideoPixels = pixels | |
| 333 | 361 | } | |
| 334 | - | case int32(ffmpeg.AVMEDIA_TYPE_AUDIO): | |
| 362 | + | case ffmpeg.AVMEDIA_TYPE_AUDIO: | |
| 335 | 363 | if audioIdx == -1 { | |
| 336 | 364 | audioIdx = i | |
| 337 | 365 | } | |
| @@ -372,7 +400,7 @@ func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, | |||
| 372 | 400 | } | |
| 373 | 401 | ||
| 374 | 402 | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |
| 375 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, nil) | |
| 403 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, http.NoBody) | |
| 376 | 404 | if err != nil { | |
| 377 | 405 | return nil, nil, fmt.Errorf("create request: %w", err) | |
| 378 | 406 | } | |
| @@ -380,13 +408,13 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||
| 380 | 408 | req.Header.Set(k, v) | |
| 381 | 409 | } | |
| 382 | 410 | ||
| 383 | - | resp, err := httpClient.Do(req) | |
| 411 | + | resp, err := httpClient.Do(req) //nolint:bodyclose // closed by returned cleanup after FFmpeg finishes reading. | |
| 384 | 412 | if err != nil { | |
| 385 | 413 | return nil, nil, fmt.Errorf("fetch source: %w", err) | |
| 386 | 414 | } | |
| 387 | 415 | ||
| 388 | 416 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |
| 389 | - | resp.Body.Close() | |
| 417 | + | _ = resp.Body.Close() | |
| 390 | 418 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |
| 391 | 419 | } | |
| 392 | 420 | ||
| @@ -395,6 +423,7 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||
| 395 | 423 | if bufSize <= 0 { | |
| 396 | 424 | return -1 | |
| 397 | 425 | } | |
| 426 | + | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |
| 398 | 427 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |
| 399 | 428 | // Retry up to 3 times for the edge case where body.Read returns | |
| 400 | 429 | // n=0, err=nil (allowed by the io.Reader contract but unusual). | |
| @@ -415,31 +444,36 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||
| 415 | 444 | ||
| 416 | 445 | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |
| 417 | 446 | if readAVIO == nil { | |
| 418 | - | resp.Body.Close() | |
| 447 | + | _ = resp.Body.Close() | |
| 419 | 448 | return nil, nil, errors.New("failed to allocate read AVIO context") | |
| 420 | 449 | } | |
| 421 | 450 | ||
| 422 | 451 | inFmtCtx := ffmpeg.FormatAllocContext() | |
| 423 | 452 | if inFmtCtx == nil { | |
| 453 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 424 | 454 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 425 | - | resp.Body.Close() | |
| 455 | + | _ = resp.Body.Close() | |
| 426 | 456 | return nil, nil, errors.New("failed to allocate input format context") | |
| 427 | 457 | } | |
| 428 | 458 | ||
| 429 | 459 | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |
| 430 | 460 | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |
| 431 | 461 | ||
| 462 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts. | |
| 432 | 463 | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | |
| 464 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 433 | 465 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 434 | - | resp.Body.Close() | |
| 466 | + | _ = resp.Body.Close() | |
| 435 | 467 | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 436 | 468 | return nil, nil, fmt.Errorf("open input: %d", ret) | |
| 437 | 469 | } | |
| 438 | 470 | ||
| 439 | 471 | cleanup := func() { | |
| 472 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |
| 440 | 473 | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 474 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |
| 441 | 475 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |
| 442 | - | resp.Body.Close() | |
| 476 | + | _ = resp.Body.Close() | |
| 443 | 477 | } | |
| 444 | 478 | return inFmtCtx, cleanup, nil | |
| 445 | 479 | } | |
| @@ -478,12 +512,14 @@ func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) { | |||
| 478 | 512 | } | |
| 479 | 513 | } | |
| 480 | 514 | ||
| 515 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries. | |
| 481 | 516 | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | |
| 482 | 517 | ffmpeg.FormatFreeContext(inFmtCtx) | |
| 483 | 518 | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | |
| 484 | 519 | } | |
| 485 | 520 | ||
| 486 | 521 | return inFmtCtx, func() { | |
| 522 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |
| 487 | 523 | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |
| 488 | 524 | }, nil | |
| 489 | 525 | } | |
| @@ -494,6 +530,7 @@ func freeInputOptions(opts *unsafe.Pointer) { | |||
| 494 | 530 | } | |
| 495 | 531 | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | |
| 496 | 532 | // itself corrupts memory and can crash inside libavutil during cleanup. | |
| 533 | + | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup. | |
| 497 | 534 | ffmpeg.DictFree(unsafe.Pointer(opts)) | |
| 498 | 535 | *opts = nil | |
| 499 | 536 | } | |
| @@ -502,6 +539,7 @@ func setInputOption(opts *unsafe.Pointer, key, value string) error { | |||
| 502 | 539 | if value == "" { | |
| 503 | 540 | return nil | |
| 504 | 541 | } | |
| 542 | + | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates. | |
| 505 | 543 | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | |
| 506 | 544 | return fmt.Errorf("set input option %q: %d", key, ret) | |
| 507 | 545 | } | |
| @@ -542,6 +580,8 @@ func formatHTTPHeaderOptions(headers map[string]string) string { | |||
| 542 | 580 | // openVideoDecoder creates and opens a decoder context for the video stream. | |
| 543 | 581 | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | |
| 544 | 582 | // falling back to the generic software decoder. | |
| 583 | + | // | |
| 584 | + | //nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup. | |
| 545 | 585 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | |
| 546 | 586 | codecID := ffmpeg.CodecParCodecID(codecPar) | |
| 547 | 587 | ||
| @@ -582,7 +622,7 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er | |||
| 582 | 622 | ||
| 583 | 623 | // openVideoEncoder creates a hardware device context and encoder for | |
| 584 | 624 | // the video stream. It uses the first available encoder from hwCaps.Encoders. | |
| 585 | - | func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.Pointer, encCtx unsafe.Pointer, err error) { | |
| 625 | + | func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx, encCtx unsafe.Pointer, err error) { | |
| 586 | 626 | if len(p.hwCaps.Encoders) == 0 { | |
| 587 | 627 | return nil, nil, errors.New("no GPU encoders available") | |
| 588 | 628 | } | |
| @@ -590,7 +630,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P | |||
| 590 | 630 | // Create the HW device context. | |
| 591 | 631 | var deviceType int32 | |
| 592 | 632 | switch p.hwCaps.API { | |
| 593 | - | case "vaapi": | |
| 633 | + | case hwAPINameVAAPI: | |
| 594 | 634 | deviceType = int32(ffmpeg.HwdeviceTypeVaapi) | |
| 595 | 635 | case "nvenc": | |
| 596 | 636 | deviceType = int32(ffmpeg.HwdeviceTypeCuda) | |
| @@ -659,13 +699,13 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||
| 659 | 699 | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | |
| 660 | 700 | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | |
| 661 | 701 | ||
| 662 | - | if p.hwCaps.API == "vaapi" { | |
| 702 | + | if p.hwCaps.API == hwAPINameVAAPI { | |
| 663 | 703 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi)) | |
| 664 | 704 | } else { | |
| 665 | 705 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12)) | |
| 666 | 706 | } | |
| 667 | 707 | ||
| 668 | - | gopSize := int32(framerate.Num / framerate.Den * 2) | |
| 708 | + | gopSize := framerate.Num / framerate.Den * 2 | |
| 669 | 709 | if gopSize <= 0 { | |
| 670 | 710 | gopSize = 60 | |
| 671 | 711 | } | |
| @@ -674,14 +714,16 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||
| 674 | 714 | ||
| 675 | 715 | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | |
| 676 | 716 | if hwDeviceRef == nil { | |
| 717 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 677 | 718 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 678 | 719 | return nil, errors.New("failed to ref HW device context for encoder") | |
| 679 | 720 | } | |
| 680 | 721 | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | |
| 681 | 722 | ||
| 682 | - | if p.hwCaps.API == "vaapi" { | |
| 723 | + | if p.hwCaps.API == hwAPINameVAAPI { | |
| 683 | 724 | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | |
| 684 | 725 | if framesRef == nil { | |
| 726 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 685 | 727 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 686 | 728 | return nil, errors.New("failed to allocate HW frames context") | |
| 687 | 729 | } | |
| @@ -689,11 +731,12 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||
| 689 | 731 | hwFramesData := ffmpeg.BufferRefData(framesRef) | |
| 690 | 732 | if hwFramesData == nil { | |
| 691 | 733 | ffmpeg.BufferUnref(&framesRef) | |
| 734 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 692 | 735 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 693 | 736 | return nil, errors.New("failed to access HW frames context payload") | |
| 694 | 737 | } | |
| 695 | 738 | ||
| 696 | - | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, 20) | |
| 739 | + | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, vaapiInitialPoolSize) | |
| 697 | 740 | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi)) | |
| 698 | 741 | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12)) | |
| 699 | 742 | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | |
| @@ -701,6 +744,7 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||
| 701 | 744 | ||
| 702 | 745 | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | |
| 703 | 746 | ffmpeg.BufferUnref(&framesRef) | |
| 747 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 704 | 748 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 705 | 749 | return nil, fmt.Errorf("init HW frames context: %d", ret) | |
| 706 | 750 | } | |
| @@ -709,6 +753,7 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||
| 709 | 753 | } | |
| 710 | 754 | ||
| 711 | 755 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |
| 756 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |
| 712 | 757 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |
| 713 | 758 | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |
| 714 | 759 | } | |
| @@ -734,8 +779,8 @@ func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, | |||
| 734 | 779 | } | |
| 735 | 780 | ||
| 736 | 781 | bitrate := int64(float64(pixels) * bitsPerPixel) | |
| 737 | - | if bitrate < 500_000 { | |
| 738 | - | bitrate = 500_000 // floor at 500 kbps | |
| 782 | + | if bitrate < minVideoBitrate { | |
| 783 | + | bitrate = minVideoBitrate // floor at 500 kbps | |
| 739 | 784 | } | |
| 740 | 785 | codecCtxSetBitRate(encCtx, bitrate) | |
| 741 | 786 | ||
| @@ -759,7 +804,7 @@ func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, | |||
| 759 | 804 | ||
| 760 | 805 | // transcodeVideoPacket decodes a video packet, encodes the resulting | |
| 761 | 806 | // frames, and writes them to the output. | |
| 762 | - | func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap ffmpeg.Stream, outVideoIdx int) error { | |
| 807 | + | func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |
| 763 | 808 | ret := ffmpeg.CodecSendPacket(decCtx, pkt) | |
| 764 | 809 | if ret < 0 { | |
| 765 | 810 | return fmt.Errorf("video decode send packet: %d", ret) | |
| @@ -772,7 +817,7 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF | |||
| 772 | 817 | break | |
| 773 | 818 | } | |
| 774 | 819 | ||
| 775 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, inStreamWrap, outVideoIdx); err != nil { | |
| 820 | + | if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, outVideoIdx); err != nil { | |
| 776 | 821 | ffmpeg.FrameUnref(frame) | |
| 777 | 822 | return err | |
| 778 | 823 | } | |
| @@ -784,12 +829,12 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF | |||
| 784 | 829 | ||
| 785 | 830 | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | |
| 786 | 831 | // writes any resulting packets to the output. | |
| 787 | - | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, inStreamWrap ffmpeg.Stream, outVideoIdx int) error { | |
| 832 | + | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |
| 788 | 833 | encFrame := frame | |
| 789 | 834 | var cleanup func() | |
| 790 | - | if p.hwCaps.API == "vaapi" { | |
| 835 | + | if p.hwCaps.API == hwAPINameVAAPI { | |
| 791 | 836 | var err error | |
| 792 | - | encFrame, cleanup, err = p.uploadVideoFrameToVAAPI(frame, encCtx) | |
| 837 | + | encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx) | |
| 793 | 838 | if err != nil { | |
| 794 | 839 | return err | |
| 795 | 840 | } | |
| @@ -823,7 +868,7 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns | |||
| 823 | 868 | return nil | |
| 824 | 869 | } | |
| 825 | 870 | ||
| 826 | - | func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |
| 871 | + | func uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |
| 827 | 872 | if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) { | |
| 828 | 873 | return frame, func() {}, nil | |
| 829 | 874 | } | |
| @@ -840,6 +885,7 @@ func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe | |||
| 840 | 885 | ||
| 841 | 886 | cleanup := func() { | |
| 842 | 887 | if hwFrame != nil { | |
| 888 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |
| 843 | 889 | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | |
| 844 | 890 | } | |
| 845 | 891 | } | |
M internal/infrastructure/transcoder/pool.go +2 -2
| @@ -39,7 +39,7 @@ func (s *session) Close() error { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // ContentType returns the MIME type of the transcoded output. | |
| 42 | - | func (s *session) ContentType() string { | |
| 42 | + | func (*session) ContentType() string { | |
| 43 | 43 | return "video/webm" | |
| 44 | 44 | } | |
| 45 | 45 | ||
| @@ -106,6 +106,6 @@ func (p *sessionPool) closeAll() { | |||
| 106 | 106 | ||
| 107 | 107 | // Close sessions outside the lock. | |
| 108 | 108 | for _, s := range snapshot { | |
| 109 | - | s.Close() | |
| 109 | + | _ = s.Close() | |
| 110 | 110 | } | |
| 111 | 111 | } | |
M internal/infrastructure/transcoder/transcoder.go +4 -4
| @@ -84,7 +84,7 @@ func (t *Transcoder) Capabilities() port.HWCapabilities { | |||
| 84 | 84 | // Start begins a transcode session. It spawns a goroutine running the | |
| 85 | 85 | // FFmpeg pipeline and returns a TranscodeSession whose Read() streams | |
| 86 | 86 | // the transcoded WebM output. The session is automatically removed from | |
| 87 | - | // the pool when the pipeline completes or the context is cancelled. | |
| 87 | + | // the pool when the pipeline completes or the context is canceled. | |
| 88 | 88 | func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) { | |
| 89 | 89 | if !t.Available() { | |
| 90 | 90 | return nil, errors.New("transcoder: no GPU encoder available") | |
| @@ -107,8 +107,8 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st | |||
| 107 | 107 | // Check capacity and register before spawning the goroutine. | |
| 108 | 108 | if err := t.pool.add(s); err != nil { | |
| 109 | 109 | cancel() | |
| 110 | - | pr.Close() | |
| 111 | - | pw.Close() | |
| 110 | + | _ = pr.Close() | |
| 111 | + | _ = pw.Close() | |
| 112 | 112 | return nil, fmt.Errorf("transcoder: %w", err) | |
| 113 | 113 | } | |
| 114 | 114 | ||
| @@ -128,7 +128,7 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st | |||
| 128 | 128 | defer func() { | |
| 129 | 129 | if r := recover(); r != nil { | |
| 130 | 130 | t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked") | |
| 131 | - | pw.CloseWithError(fmt.Errorf("transcode session panicked: %v", r)) | |
| 131 | + | _ = pw.CloseWithError(fmt.Errorf("transcode session panicked: %v", r)) | |
| 132 | 132 | } | |
| 133 | 133 | }() | |
| 134 | 134 | p.run(sessionCtx) | |
M internal/logging/logger_test.go +4 -4
| @@ -27,8 +27,8 @@ func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | |||
| 27 | 27 | t.Fatalf("write before close returned %d, want %d", n, len("first\n")) | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | if err := writer.Close(); err != nil { | |
| 31 | - | t.Fatalf("close writer: %v", err) | |
| 30 | + | if closeErr := writer.Close(); closeErr != nil { | |
| 31 | + | t.Fatalf("close writer: %v", closeErr) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | 34 | n, err = writer.Write([]byte("second\n")) | |
| @@ -60,8 +60,8 @@ func TestSessionFileWriter_IgnoresExternallyClosedFile(t *testing.T) { | |||
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | writer := newSessionFileWriter(file) | |
| 63 | - | if err := file.Close(); err != nil { | |
| 64 | - | t.Fatalf("close underlying file: %v", err) | |
| 63 | + | if closeErr := file.Close(); closeErr != nil { | |
| 64 | + | t.Fatalf("close underlying file: %v", closeErr) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | n, err := writer.Write([]byte("late\n")) | |
M internal/ui/adapter/favicon.go +72 -71
| @@ -113,11 +113,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 113 | 113 | ||
| 114 | 114 | // Handle internal dumb:// scheme URLs - use app logo | |
| 115 | 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | |
| 116 | - | texture := a.textureFromBytesOnGTK(ctx, a.getLogoBytes()) | |
| 117 | - | if texture != nil { | |
| 118 | - | a.setTexture(a.internalDomain, texture) | |
| 119 | - | } | |
| 120 | - | callback(texture) | |
| 116 | + | callback(a.getOrCreateLogoTexture(ctx)) | |
| 121 | 117 | return | |
| 122 | 118 | } | |
| 123 | 119 | ||
| @@ -152,82 +148,87 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 152 | 148 | } | |
| 153 | 149 | } | |
| 154 | 150 | ||
| 155 | - | // Helper to invoke callback on main thread | |
| 156 | - | invokeCallback := func(texture *gdk.Texture) { | |
| 157 | - | log.Debug(). | |
| 158 | - | Str("domain", domain). | |
| 159 | - | Bool("texture_nil", texture == nil). | |
| 160 | - | Msg("favicon: scheduling callback on GTK") | |
| 161 | - | cb := glib.SourceFunc(func(uintptr) bool { | |
| 162 | - | log.Debug(). | |
| 163 | - | Str("domain", domain). | |
| 164 | - | Bool("texture_nil", texture == nil). | |
| 165 | - | Msg("favicon: callback executing on GTK") | |
| 166 | - | callback(texture) | |
| 167 | - | return false | |
| 151 | + | // Try engine FaviconDatabase if available | |
| 152 | + | if a.faviconDB != nil { | |
| 153 | + | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | |
| 154 | + | a.handleEngineFaviconDBTexture(ctx, domain, callback, tex) | |
| 168 | 155 | }) | |
| 169 | - | glib.IdleAdd(&cb, 0) | |
| 156 | + | return | |
| 170 | 157 | } | |
| 171 | 158 | ||
| 172 | - | // Helper to fetch via service and invoke callback | |
| 173 | - | fetchViaService := func() { | |
| 174 | - | go func() { | |
| 175 | - | log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") | |
| 176 | - | data, err := a.service.Get(ctx, domain) | |
| 177 | - | if err != nil || len(data) == 0 { | |
| 178 | - | log.Debug(). | |
| 179 | - | Err(err). | |
| 180 | - | Str("domain", domain). | |
| 181 | - | Int("bytes", len(data)). | |
| 182 | - | Msg("favicon: fetchViaService empty/error result") | |
| 183 | - | invokeCallback(nil) | |
| 184 | - | return | |
| 185 | - | } | |
| 159 | + | // No WebKit DB, fetch via service directly | |
| 160 | + | log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") | |
| 161 | + | a.fetchViaService(ctx, domain, callback) | |
| 162 | + | } | |
| 186 | 163 | ||
| 164 | + | func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) { | |
| 165 | + | log := logging.FromContext(ctx) | |
| 166 | + | log.Debug(). | |
| 167 | + | Str("domain", domain). | |
| 168 | + | Bool("texture_nil", texture == nil). | |
| 169 | + | Msg("favicon: scheduling callback on GTK") | |
| 170 | + | cb := glib.SourceFunc(func(uintptr) bool { | |
| 171 | + | log.Debug(). | |
| 172 | + | Str("domain", domain). | |
| 173 | + | Bool("texture_nil", texture == nil). | |
| 174 | + | Msg("favicon: callback executing on GTK") | |
| 175 | + | callback(texture) | |
| 176 | + | return false | |
| 177 | + | }) | |
| 178 | + | glib.IdleAdd(&cb, 0) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) { | |
| 182 | + | go func() { | |
| 183 | + | log := logging.FromContext(ctx) | |
| 184 | + | log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") | |
| 185 | + | data, err := a.service.Get(ctx, domain) | |
| 186 | + | if err != nil || len(data) == 0 { | |
| 187 | 187 | log.Debug(). | |
| 188 | + | Err(err). | |
| 188 | 189 | Str("domain", domain). | |
| 189 | 190 | Int("bytes", len(data)). | |
| 190 | - | Msg("favicon: fetchViaService fetched data") | |
| 191 | - | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 192 | - | if texture != nil { | |
| 193 | - | a.setTexture(domain, texture) | |
| 194 | - | } | |
| 195 | - | log.Debug(). | |
| 196 | - | Str("domain", domain). | |
| 197 | - | Bool("texture_nil", texture == nil). | |
| 198 | - | Msg("favicon: fetchViaService end") | |
| 199 | - | invokeCallback(texture) | |
| 200 | - | }() | |
| 201 | - | } | |
| 191 | + | Msg("favicon: fetchViaService empty/error result") | |
| 192 | + | a.invokeCallbackOnGTK(ctx, domain, callback, nil) | |
| 193 | + | return | |
| 194 | + | } | |
| 202 | 195 | ||
| 203 | - | // Try engine FaviconDatabase if available | |
| 204 | - | if a.faviconDB != nil { | |
| 205 | - | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | |
| 206 | - | log.Debug(). | |
| 207 | - | Str("domain", domain). | |
| 208 | - | Bool("texture_nil", tex == nil). | |
| 209 | - | Msg("favicon: engine db callback") | |
| 210 | - | if tex == nil { | |
| 211 | - | log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") | |
| 212 | - | fetchViaService() | |
| 213 | - | return | |
| 214 | - | } | |
| 215 | - | // Convert port.Texture to *gdk.Texture | |
| 216 | - | gdkTex, ok := tex.(*gdk.Texture) | |
| 217 | - | if !ok { | |
| 218 | - | fetchViaService() | |
| 219 | - | return | |
| 220 | - | } | |
| 221 | - | a.setTexture(domain, gdkTex) | |
| 222 | - | invokeCallback(gdkTex) | |
| 223 | - | a.saveFaviconToDisk(ctx, domain, gdkTex) | |
| 224 | - | }) | |
| 196 | + | log.Debug(). | |
| 197 | + | Str("domain", domain). | |
| 198 | + | Int("bytes", len(data)). | |
| 199 | + | Msg("favicon: fetchViaService fetched data") | |
| 200 | + | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 201 | + | if texture != nil { | |
| 202 | + | a.setTexture(domain, texture) | |
| 203 | + | } | |
| 204 | + | log.Debug(). | |
| 205 | + | Str("domain", domain). | |
| 206 | + | Bool("texture_nil", texture == nil). | |
| 207 | + | Msg("favicon: fetchViaService end") | |
| 208 | + | a.invokeCallbackOnGTK(ctx, domain, callback, texture) | |
| 209 | + | }() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) { | |
| 213 | + | log := logging.FromContext(ctx) | |
| 214 | + | log.Debug(). | |
| 215 | + | Str("domain", domain). | |
| 216 | + | Bool("texture_nil", tex == nil). | |
| 217 | + | Msg("favicon: engine db callback") | |
| 218 | + | if tex == nil { | |
| 219 | + | log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") | |
| 220 | + | a.fetchViaService(ctx, domain, callback) | |
| 225 | 221 | return | |
| 226 | 222 | } | |
| 227 | - | ||
| 228 | - | // No WebKit DB, fetch via service directly | |
| 229 | - | log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") | |
| 230 | - | fetchViaService() | |
| 223 | + | // Convert port.Texture to *gdk.Texture | |
| 224 | + | gdkTex, ok := tex.(*gdk.Texture) | |
| 225 | + | if !ok { | |
| 226 | + | a.fetchViaService(ctx, domain, callback) | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | a.setTexture(domain, gdkTex) | |
| 230 | + | a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex) | |
| 231 | + | a.saveFaviconToDisk(ctx, domain, gdkTex) | |
| 231 | 232 | } | |
| 232 | 233 | ||
| 233 | 234 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | |