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 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/purego-cef v0.8.0 | 6 | github.com/bnema/purego-cef v0.8.0 | ||
| 7 | github.com/bnema/purego-ffmpeg v0.3.0 | 7 | github.com/bnema/purego-ffmpeg v0.3.0 | ||
| 8 | + | github.com/bnema/purego-pipewire v0.0.0-00010101000000-000000000000 | |||
| 8 | github.com/bnema/purego-sqlite v0.1.1 | 9 | github.com/bnema/purego-sqlite v0.1.1 | ||
| 9 | github.com/bnema/puregotk v0.3.0 | 10 | github.com/bnema/puregotk v0.3.0 | ||
| 10 | github.com/bnema/puregotk-webkit v0.0.16 | 11 | github.com/bnema/puregotk-webkit v0.0.16 | ||
| @@ -30,6 +31,8 @@ replace github.com/bnema/purego-cef => ../purego-cef | |||||
| 30 | 31 | ||||
| 31 | replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg | 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 | require ( | 36 | require ( | ||
| 34 | github.com/atotto/clipboard v0.1.4 // indirect | 37 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 35 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 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 | "fmt" | 5 | "fmt" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |||
| 8 | "github.com/bnema/dumber/internal/infrastructure/cef" | 9 | "github.com/bnema/dumber/internal/infrastructure/cef" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/config" | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 10 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 11 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| @@ -42,7 +43,8 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 42 | ) | 43 | ) | ||
| 43 | case config.EngineTypeCEF: | 44 | case config.EngineTypeCEF: | ||
| 44 | cefCfg := cfg.Engine.CEF | 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 | default: | 48 | default: | ||
| 47 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 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 | // BuildHandlerDeps constructs handler dependencies from the config manager. | 12 | // BuildHandlerDeps constructs handler dependencies from the config manager. | ||
| 13 | // It combines keybinding use cases and config save into a single struct. | 13 | // It combines keybinding use cases and config save into a single struct. | ||
| 14 | // The context is accepted for future use (e.g., passing to constructors that may need cancellation). | 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 | mgr := config.GetManager() | 16 | mgr := config.GetManager() | ||
| 17 | if mgr == nil { | 17 | if mgr == nil { | ||
| 18 | return nil, fmt.Errorf("config manager not initialized") | 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 | const dumbSchemeName = "dumb" | 9 | const dumbSchemeName = "dumb" | ||
| 10 | 10 | ||||
| 11 | func dumbSchemeOptions() int32 { | 11 | func dumbSchemeOptions() int32 { | ||
| 12 | - | return int32(purecef.SchemeOptionsSchemeOptionStandard | | 12 | + | return purecef.SchemeOptionsSchemeOptionStandard | |
| 13 | purecef.SchemeOptionsSchemeOptionSecure | | 13 | purecef.SchemeOptionsSchemeOptionSecure | | ||
| 14 | purecef.SchemeOptionsSchemeOptionCorsEnabled | | 14 | purecef.SchemeOptionsSchemeOptionCorsEnabled | | ||
| 15 | purecef.SchemeOptionsSchemeOptionCspBypassing | | 15 | purecef.SchemeOptionsSchemeOptionCspBypassing | | ||
| 16 | - | purecef.SchemeOptionsSchemeOptionFetchEnabled) | 16 | + | purecef.SchemeOptionsSchemeOptionFetchEnabled |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | func registerDumbScheme(registrar purecef.SchemeRegistrar) { | 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 | stub := &schemeRegistrarStub{} | 25 | stub := &schemeRegistrarStub{} | ||
| 26 | registerDumbScheme(stub) | 26 | registerDumbScheme(stub) | ||
| 27 | 27 | ||||
| 28 | - | wantOptions := int32(purecef.SchemeOptionsSchemeOptionStandard | | 28 | + | wantOptions := purecef.SchemeOptionsSchemeOptionStandard | |
| 29 | purecef.SchemeOptionsSchemeOptionSecure | | 29 | purecef.SchemeOptionsSchemeOptionSecure | | ||
| 30 | purecef.SchemeOptionsSchemeOptionCorsEnabled | | 30 | purecef.SchemeOptionsSchemeOptionCorsEnabled | | ||
| 31 | purecef.SchemeOptionsSchemeOptionCspBypassing | | 31 | purecef.SchemeOptionsSchemeOptionCspBypassing | | ||
| 32 | - | purecef.SchemeOptionsSchemeOptionFetchEnabled) | 32 | + | purecef.SchemeOptionsSchemeOptionFetchEnabled |
| 33 | 33 | ||||
| 34 | if stub.calls != 1 { | 34 | if stub.calls != 1 { | ||
| 35 | t.Fatalf("AddCustomScheme call count = %d, want 1", stub.calls) | 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 | function monitorReplacement(video) { | 252 | function monitorReplacement(video) { | ||
| 253 | if (!video || video._dumberReplacementDiag) return; | 253 | if (!video || video._dumberReplacementDiag) return; | ||
| 254 | video._dumberReplacementDiag = true; | 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 | video.addEventListener(evt, function() { | 259 | video.addEventListener(evt, function() { | ||
| 257 | var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none'; | 260 | var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none'; | ||
| 258 | console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState, | 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 | activeWebViews sync.Map // map[port.WebViewID]*WebView | 32 | activeWebViews sync.Map // map[port.WebViewID]*WebView | ||
| 33 | activeCount atomic.Int32 | 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 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | 36 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | ||
| 37 | shutdownNotify chan struct{} | 37 | shutdownNotify chan struct{} | ||
| 38 | 38 | ||||
| @@ -42,14 +42,13 @@ type Engine struct { | |||||
| 42 | childLaunchGPU atomic.Uint64 | 42 | childLaunchGPU atomic.Uint64 | ||
| 43 | childLaunchOther atomic.Uint64 | 43 | childLaunchOther atomic.Uint64 | ||
| 44 | 44 | ||||
| 45 | - | browserCreateRequests atomic.Uint64 | 45 | + | browserCreateRequests atomic.Uint64 |
| 46 | - | browserAfterCreated atomic.Uint64 | 46 | + | browserAfterCreated atomic.Uint64 |
| 47 | - | browserCreateLastAtMs atomic.Int64 | 47 | + | browserCreateLastAtMs atomic.Int64 |
| 48 | - | browserCreateLastResult atomic.Int32 | 48 | + | browserCreateLastResult atomic.Int32 |
| 49 | - | browserCreateLastWidth atomic.Int32 | 49 | + | browserCreateLastWidth atomic.Int32 |
| 50 | - | browserCreateLastHeight atomic.Int32 | 50 | + | browserCreateLastHeight atomic.Int32 |
| 51 | - | lastStallLoggedCreateSeq atomic.Uint64 | 51 | + | browserCreateComplete atomic.Bool |
| 52 | - | browserCreateComplete atomic.Bool | |||
| 53 | 52 | ||||
| 54 | transcoderState transcoderStartupState | 53 | transcoderState transcoderStartupState | ||
| 55 | transcoderStateLogged atomic.Bool | 54 | transcoderStateLogged atomic.Bool | ||
| @@ -166,6 +165,7 @@ func (e *Engine) closeActiveWebViews() { | |||||
| 166 | } | 165 | } | ||
| 167 | 166 | ||||
| 168 | log := logging.FromContext(e.ctx) | 167 | log := logging.FromContext(e.ctx) | ||
| 168 | + | e.shutdownNotify = make(chan struct{}, 1) | |||
| 169 | webViews := make([]*WebView, 0, e.activeWebViewCount()) | 169 | webViews := make([]*WebView, 0, e.activeWebViewCount()) | ||
| 170 | e.activeWebViews.Range(func(_, value any) bool { | 170 | e.activeWebViews.Range(func(_, value any) bool { | ||
| 171 | wv, ok := value.(*WebView) | 171 | wv, ok := value.(*WebView) | ||
| @@ -183,9 +183,6 @@ func (e *Engine) closeActiveWebViews() { | |||||
| 183 | Int("count", len(webViews)). | 183 | Int("count", len(webViews)). | ||
| 184 | Msg("cef: closing active webviews before shutdown") | 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 | for _, wv := range webViews { | 186 | for _, wv := range webViews { | ||
| 190 | wv.Destroy() | 187 | wv.Destroy() | ||
| 191 | } | 188 | } | ||
| @@ -262,8 +259,6 @@ func (e *Engine) SetHandlerContext(ctx context.Context) { | |||||
| 262 | e.logTranscoderStartupState() | 259 | e.logTranscoderStartupState() | ||
| 263 | } | 260 | } | ||
| 264 | 261 | ||||
| 265 | - | const browserCreateStallWarnMS int64 = 1500 | |||
| 266 | - | ||||
| 267 | func (e *Engine) recordContextInitialized() { | 262 | func (e *Engine) recordContextInitialized() { | ||
| 268 | count := e.contextInitializedCount.Add(1) | 263 | count := e.contextInitializedCount.Add(1) | ||
| 269 | logging.FromContext(e.ctx).Debug(). | 264 | logging.FromContext(e.ctx).Debug(). | ||
| @@ -331,40 +326,6 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) { | |||||
| 331 | Msg("cef: browser created") | 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 | func (e *Engine) currentContext() context.Context { | 329 | func (e *Engine) currentContext() context.Context { | ||
| 369 | if e == nil || e.ctx == nil { | 330 | if e == nil || e.ctx == nil { | ||
| 370 | return context.Background() | 331 | return context.Background() | ||
M internal/infrastructure/cef/engine_init.go +9 -19
| @@ -24,7 +24,12 @@ import ( | |||||
| 24 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | 24 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | ||
| 25 | 25 | ||||
| 26 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | 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 | logger := logging.FromContext(ctx) | 33 | logger := logging.FromContext(ctx) | ||
| 29 | cleanStaleSingletonLocks(logger) | 34 | cleanStaleSingletonLocks(logger) | ||
| 30 | 35 | ||||
| @@ -55,7 +60,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c | |||||
| 55 | } | 60 | } | ||
| 56 | os.Args = savedArgs | 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 | // prepareCEFSettings builds purecef.Settings from the engine config. | 66 | // prepareCEFSettings builds purecef.Settings from the engine config. | ||
| @@ -118,7 +123,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||||
| 118 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | 123 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | ||
| 119 | func wireEngine( | 124 | func wireEngine( | ||
| 120 | ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig, | 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 | ) (*Engine, error) { | 127 | ) (*Engine, error) { | ||
| 123 | gl, err := newGLLoader() | 128 | gl, err := newGLLoader() | ||
| 124 | if err != nil { | 129 | if err != nil { | ||
| @@ -156,6 +161,7 @@ func wireEngine( | |||||
| 156 | windowlessFrameRate: windowlessFrameRate, | 161 | windowlessFrameRate: windowlessFrameRate, | ||
| 157 | enableContextMenuHandler: cfg.EnableContextMenuHandler, | 162 | enableContextMenuHandler: cfg.EnableContextMenuHandler, | ||
| 158 | transcoder: mediaTranscoder, | 163 | transcoder: mediaTranscoder, | ||
| 164 | + | audioOutputFactory: audioFactory, | |||
| 159 | }) | 165 | }) | ||
| 160 | pool := newWebViewPool(factory) | 166 | pool := newWebViewPool(factory) | ||
| 161 | 167 | ||||
| @@ -279,22 +285,6 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 { | |||||
| 279 | return 1 | 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 | // appendIfMissing appends flag to args if it's not already present. | 288 | // appendIfMissing appends flag to args if it's not already present. | ||
| 299 | func appendIfMissing(args []string, flag string) []string { | 289 | func appendIfMissing(args []string, flag string) []string { | ||
| 300 | for _, a := range args { | 290 | for _, a := range args { | ||
M internal/infrastructure/cef/engine_init_test.go +10 -0
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | "github.com/rs/zerolog" | 10 | "github.com/rs/zerolog" | ||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| @@ -64,3 +65,12 @@ func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) { | |||||
| 64 | // In dev mode, should resolve under .dev/dumber/cef_user_data | 65 | // In dev mode, should resolve under .dev/dumber/cef_user_data | ||
| 65 | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | 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 | enableContextMenuHandler bool | 25 | enableContextMenuHandler bool | ||
| 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | ||
| 27 | transcoder port.MediaTranscoder | 27 | transcoder port.MediaTranscoder | ||
| 28 | + | audioOutputFactory port.AudioOutputFactory | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | type webViewFactoryOptions struct { | 31 | type webViewFactoryOptions struct { | ||
| @@ -32,6 +33,7 @@ type webViewFactoryOptions struct { | |||||
| 32 | windowlessFrameRate int32 | 33 | windowlessFrameRate int32 | ||
| 33 | enableContextMenuHandler bool | 34 | enableContextMenuHandler bool | ||
| 34 | transcoder port.MediaTranscoder | 35 | transcoder port.MediaTranscoder | ||
| 36 | + | audioOutputFactory port.AudioOutputFactory | |||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| 37 | // newWebViewFactory returns a factory that will create WebViews using the | 39 | // newWebViewFactory returns a factory that will create WebViews using the | ||
| @@ -50,6 +52,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||||
| 50 | windowlessFrameRate: opts.windowlessFrameRate, | 52 | windowlessFrameRate: opts.windowlessFrameRate, | ||
| 51 | enableContextMenuHandler: opts.enableContextMenuHandler, | 53 | enableContextMenuHandler: opts.enableContextMenuHandler, | ||
| 52 | transcoder: opts.transcoder, | 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 | pipeline := newRenderPipeline(ctx, f.gl, f.scale) | 72 | pipeline := newRenderPipeline(ctx, f.gl, f.scale) | ||
| 70 | 73 | ||||
| 71 | wv := &WebView{ | 74 | wv := &WebView{ | ||
| 72 | - | id: id, | 75 | + | id: id, |
| 73 | - | ctx: ctx, | 76 | + | ctx: ctx, |
| 74 | - | engine: f.engine, | 77 | + | engine: f.engine, |
| 75 | - | pipeline: pipeline, | 78 | + | pipeline: pipeline, |
| 79 | + | audioOutputFactory: f.audioOutputFactory, | |||
| 76 | } | 80 | } | ||
| 77 | 81 | ||||
| 78 | var transcodingHandler purecef.ResourceRequestHandler | 82 | var transcodingHandler purecef.ResourceRequestHandler | ||
| @@ -135,13 +139,21 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 135 | // Defer browser creation until the GL area has a non-zero size. | 139 | // Defer browser creation until the GL area has a non-zero size. | ||
| 136 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | 140 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | ||
| 137 | // is not yet realized at this point. | 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 | wv.pendingCreate = &pendingBrowserCreate{ | 151 | wv.pendingCreate = &pendingBrowserCreate{ | ||
| 139 | - | windowInfo: &windowInfo, | 152 | + | windowInfo: windowInfo, |
| 140 | client: client, | 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 | pipeline.onFirstResize = func(w, h int32) { | 157 | pipeline.onFirstResize = func(w, h int32) { | ||
| 146 | log := logging.FromContext(ctx) | 158 | log := logging.FromContext(ctx) | ||
| 147 | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | 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 | host.WasResized() | 210 | host.WasResized() | ||
| 199 | } | 211 | } | ||
| 200 | } | 212 | } | ||
| 201 | - | ||||
| 202 | - | return wv, nil | |||
| 203 | } | 213 | } | ||
| 204 | 214 | ||||
| 205 | // createRelatedWarned ensures the session sharing warning is logged only once. | 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 | rp.mu.Unlock() | 293 | rp.mu.Unlock() | ||
| 294 | 294 | ||||
| 295 | - | gl := rp.gl | |||
| 296 | - | ||||
| 297 | if !rp.glReady || sizeChanged { | 295 | if !rp.glReady || sizeChanged { | ||
| 298 | rp.initGL(uploadWidth, uploadHeight) | 296 | rp.initGL(uploadWidth, uploadHeight) | ||
| 299 | } | 297 | } | ||
| 300 | 298 | ||||
| 301 | if !rp.glReady { | 299 | if !rp.glReady { | ||
| 302 | - | renderDuration := time.Since(renderStartedAt) | 300 | + | rp.recordGLRenderDuration(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() | |||
| 311 | return true | 301 | return true | ||
| 312 | } | 302 | } | ||
| 313 | 303 | ||||
| @@ -315,22 +305,12 @@ func (rp *renderPipeline) onGLRender() bool { | |||||
| 315 | uploadStartedAt := time.Now() | 305 | uploadStartedAt := time.Now() | ||
| 316 | uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq) | 306 | uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq) | ||
| 317 | if didUpload { | 307 | if didUpload { | ||
| 318 | - | uploadDuration := time.Since(uploadStartedAt) | 308 | + | rp.recordGLUploadDuration(uploadStartedAt, uploadedBytes, fullUpload) |
| 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() | |||
| 330 | } | 309 | } | ||
| 331 | } | 310 | } | ||
| 332 | 311 | ||||
| 333 | // Draw the main view texture. | 312 | // Draw the main view texture. | ||
| 313 | + | gl := rp.gl | |||
| 334 | w, h, _ := rp.viewRectSize() | 314 | w, h, _ := rp.viewRectSize() | ||
| 335 | gl.viewport(0, 0, w, h) | 315 | gl.viewport(0, 0, w, h) | ||
| 336 | gl.clearColor(0, 0, 0, 1) | 316 | gl.clearColor(0, 0, 0, 1) | ||
| @@ -342,13 +322,7 @@ func (rp *renderPipeline) onGLRender() bool { | |||||
| 342 | } | 322 | } | ||
| 343 | 323 | ||||
| 344 | renderDuration := time.Since(renderStartedAt) | 324 | renderDuration := time.Since(renderStartedAt) | ||
| 345 | - | rp.diagMu.Lock() | 325 | + | rp.recordGLRenderDuration(renderStartedAt) |
| 346 | - | rp.glRenderCount++ | |||
| 347 | - | rp.renderTotalNs += uint64(renderDuration) | |||
| 348 | - | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |||
| 349 | - | rp.maxRenderNs = renderDuration.Nanoseconds() | |||
| 350 | - | } | |||
| 351 | - | rp.diagMu.Unlock() | |||
| 352 | rp.maybeLogDiagnostics() | 326 | rp.maybeLogDiagnostics() | ||
| 353 | if rp.ctx != nil { | 327 | if rp.ctx != nil { | ||
| 354 | logging.FromContext(rp.ctx).Trace(). | 328 | logging.FromContext(rp.ctx).Trace(). | ||
| @@ -363,6 +337,33 @@ func (rp *renderPipeline) onGLRender() bool { | |||||
| 363 | return true | 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 | func (rp *renderPipeline) drawTexture(texture uint32) { | 367 | func (rp *renderPipeline) drawTexture(texture uint32) { | ||
| 367 | if texture == 0 { | 368 | if texture == 0 { | ||
| 368 | return | 369 | return | ||
| @@ -404,63 +405,76 @@ func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, | |||||
| 404 | 405 | ||||
| 405 | bufSize := int64(w) * int64(h) * 4 | 406 | bufSize := int64(w) * int64(h) * 4 | ||
| 406 | stride := int(w) * 4 | 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. | 413 | + | // Swap PBO index for next frame. |
| 409 | - | gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[rp.pboIndex]) | 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 | gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw) | 434 | gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw) | ||
| 413 | mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly) | 435 | mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly) | ||
| 414 | if mapped == nil { | 436 | if mapped == nil { | ||
| 415 | gl.bindBuffer(glPixelUnpackBuffer, 0) | 437 | gl.bindBuffer(glPixelUnpackBuffer, 0) | ||
| 416 | - | return 0, false, false | 438 | + | return 0, false, 0, false |
| 417 | } | 439 | } | ||
| 418 | 440 | ||||
| 419 | mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize)) | 441 | mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize)) | ||
| 420 | - | uploadedBytes := uint64(0) | 442 | + | defer gl.bindBuffer(glPixelUnpackBuffer, 0) |
| 421 | - | fullUpload := false | |||
| 422 | - | dirtyRects := make([]rect, 0) | |||
| 423 | 443 | ||||
| 424 | rp.mu.Lock() | 444 | rp.mu.Lock() | ||
| 425 | if !rp.needsUpload { | 445 | if !rp.needsUpload { | ||
| 426 | rp.mu.Unlock() | 446 | rp.mu.Unlock() | ||
| 427 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | 447 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | ||
| 428 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | 448 | + | return 0, false, 0, false |
| 429 | - | return 0, false, false | |||
| 430 | } | 449 | } | ||
| 431 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | 450 | + | return 0, false, 0, false |
| 432 | - | return 0, false, false | |||
| 433 | } | 451 | } | ||
| 434 | 452 | ||||
| 435 | if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight { | 453 | if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight { | ||
| 436 | rp.mu.Unlock() | 454 | rp.mu.Unlock() | ||
| 437 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | 455 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | ||
| 438 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | 456 | + | return 0, false, 0, false |
| 439 | - | return 0, false, false | |||
| 440 | } | 457 | } | ||
| 441 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |||
| 442 | if rp.ctx != nil { | 458 | if rp.ctx != nil { | ||
| 443 | logging.FromContext(rp.ctx).Trace(). | 459 | logging.FromContext(rp.ctx).Trace(). | ||
| 444 | Uint64("render_seq", renderSeq). | 460 | Uint64("render_seq", renderSeq). | ||
| 445 | Uint64("paint_seq", paintSeq). | 461 | Uint64("paint_seq", paintSeq). | ||
| 446 | Msg("cef: skipping upload while newer surface resize is pending") | 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 | if fullUpload { | 469 | if fullUpload { | ||
| 453 | copied := copy(mappedSlice, rp.staging) | 470 | copied := copy(mappedSlice, rp.staging) | ||
| 454 | if copied < len(mappedSlice) { | 471 | if copied < len(mappedSlice) { | ||
| 455 | clear(mappedSlice[copied:]) | 472 | clear(mappedSlice[copied:]) | ||
| 456 | } | 473 | } | ||
| 457 | - | uploadedBytes = uint64(copied) | |||
| 458 | } else { | 474 | } else { | ||
| 459 | dirtyRects = append(dirtyRects, rp.dirtyRects...) | 475 | dirtyRects = append(dirtyRects, rp.dirtyRects...) | ||
| 460 | - | uploadedBytes = copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects) | |||
| 461 | } | 476 | } | ||
| 462 | - | rp.needsUpload = false | 477 | + | rp.needsUpload, rp.forceFullUpload = false, false |
| 463 | - | rp.forceFullUpload = false | |||
| 464 | rp.dirtyRects = rp.dirtyRects[:0] | 478 | rp.dirtyRects = rp.dirtyRects[:0] | ||
| 465 | rp.mu.Unlock() | 479 | rp.mu.Unlock() | ||
| 466 | 480 | ||||
| @@ -472,47 +486,30 @@ func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, | |||||
| 472 | Bool("full_upload", fullUpload). | 486 | Bool("full_upload", fullUpload). | ||
| 473 | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | 487 | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | ||
| 474 | } | 488 | } | ||
| 475 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | 489 | + | return 0, fullUpload, dirtyRectCount, false |
| 476 | - | return 0, fullUpload, false | |||
| 477 | } | 490 | } | ||
| 478 | 491 | ||||
| 479 | - | // Transfer PBO → texture. With PBO bound, the last arg is a byte offset. | |||
| 480 | gl.bindTexture(glTexture2D, rp.texture) | 492 | gl.bindTexture(glTexture2D, rp.texture) | ||
| 481 | if fullUpload { | 493 | if fullUpload { | ||
| 482 | gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil) | 494 | gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil) | ||
| 483 | - | } else { | 495 | + | gl.bindTexture(glTexture2D, 0) |
| 484 | - | // Use GL_UNPACK_ROW_LENGTH so GL understands the PBO's full-width | 496 | + | return uint64(len(mappedSlice)), true, dirtyRectCount, true |
| 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) | |||
| 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) | 499 | + | gl.pixelStorei(glUnpackRowLength, w) |
| 505 | - | if rp.ctx != nil { | 500 | + | uploadedBytes := copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects) |
| 506 | - | logging.FromContext(rp.ctx).Trace(). | 501 | + | for _, rawRect := range dirtyRects { |
| 507 | - | Uint64("render_seq", renderSeq). | 502 | + | r, ok := clampDirtyRect(rawRect, w, h) |
| 508 | - | Uint64("paint_seq", paintSeq). | 503 | + | if !ok { |
| 509 | - | Uint64("uploaded_bytes", uploadedBytes). | 504 | + | continue |
| 510 | - | Int("dirty_rect_count", len(dirtyRects)). | 505 | + | } |
| 511 | - | Bool("full_upload", fullUpload). | 506 | + | offset := uintptr(int(r.Y)*stride + int(r.X)*4) |
| 512 | - | Int("next_pbo_index", rp.pboIndex). | 507 | + | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, |
| 513 | - | Msg("cef: uploadToPBO end") | 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 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | 515 | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | ||
| @@ -1042,15 +1039,16 @@ func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect { | |||||
| 1042 | for changed { | 1039 | for changed { | ||
| 1043 | changed = false | 1040 | changed = false | ||
| 1044 | for i := 0; i < len(merged); i++ { | 1041 | for i := 0; i < len(merged); i++ { | ||
| 1045 | - | for j := i + 1; j < len(merged); j++ { | 1042 | + | for j := i + 1; j < len(merged); { |
| 1046 | - | if rectsTouch(merged[i], merged[j]) { | 1043 | + | if !rectsTouch(merged[i], merged[j]) { |
| 1047 | - | merged[i] = boundingBox(merged[i], merged[j]) | 1044 | + | j++ |
| 1048 | - | // Remove j by swapping with last element. | 1045 | + | continue |
| 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 | |||
| 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 | // Scheme path constants matching WebKit's naming. | 29 | // Scheme path constants matching WebKit's naming. | ||
| 30 | const ( | 30 | const ( | ||
| 31 | - | homePath = "home" | 31 | + | homePath = "home" |
| 32 | - | configPath = "config" | 32 | + | configPath = "config" |
| 33 | - | webrtcPath = "webrtc" | 33 | + | webrtcPath = "webrtc" |
| 34 | - | errorPath = "error" | 34 | + | errorPath = "error" |
| 35 | - | indexHTML = "index.html" | 35 | + | indexHTML = "index.html" |
| 36 | + | maxSchemeTruncatedURLLength = 240 | |||
| 36 | ) | 37 | ) | ||
| 37 | 38 | ||||
| 38 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | 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 | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | 194 | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | ||
| 194 | h.logger.Info(). | 195 | h.logger.Info(). | ||
| 195 | - | Str("source_url", logging.TruncateURL(sourceURL, 240)). | 196 | + | Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)). |
| 196 | Int("forwarded_header_count", len(headers)). | 197 | Int("forwarded_header_count", len(headers)). | ||
| 197 | Msg("scheme handler returning transcoding stream") | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| @@ -22,6 +23,11 @@ import ( | |||||
| 22 | 23 | ||||
| 23 | var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | 24 | var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | ||
| 24 | 25 | ||||
| 26 | + | const ( | |||
| 27 | + | maxTranscodingURLLength = 240 | |||
| 28 | + | httpStatusOK = 200 | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 25 | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | 31 | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | ||
| 26 | // accepts a purecef.ResourceHandler, and the struct implements all methods. | 32 | // accepts a purecef.ResourceHandler, and the struct implements all methods. | ||
| 27 | 33 | ||||
| @@ -42,13 +48,16 @@ type transcodingResourceHandler struct { | |||||
| 42 | 48 | ||||
| 43 | // Open starts the transcode session asynchronously. CEF will call | 49 | // Open starts the transcode session asynchronously. CEF will call | ||
| 44 | // GetResponseHeaders and Read once the callback fires. | 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 | // Signal async handling: set handleRequest = 0. | 54 | // Signal async handling: set handleRequest = 0. | ||
| 47 | *(*int32)(handleRequest) = 0 | 55 | *(*int32)(handleRequest) = 0 | ||
| 48 | 56 | ||||
| 49 | log := rh.logger() | 57 | log := rh.logger() | ||
| 58 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 50 | log.Info(). | 59 | log.Info(). | ||
| 51 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | 60 | + | Str("source_url", sourceURL). |
| 52 | Int("forwarded_header_count", len(rh.headers)). | 61 | Int("forwarded_header_count", len(rh.headers)). | ||
| 53 | Msg("cef: starting transcoding resource stream") | 62 | Msg("cef: starting transcoding resource stream") | ||
| 54 | 63 | ||||
| @@ -57,7 +66,7 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||||
| 57 | if err != nil { | 66 | if err != nil { | ||
| 58 | log.Error(). | 67 | log.Error(). | ||
| 59 | Err(err). | 68 | Err(err). | ||
| 60 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | 69 | + | Str("source_url", sourceURL). |
| 61 | Msg("cef: failed to start transcoding resource stream") | 70 | Msg("cef: failed to start transcoding resource stream") | ||
| 62 | // Let CEF know we failed — Cancel will be called. | 71 | // Let CEF know we failed — Cancel will be called. | ||
| 63 | rh.cancel() | 72 | rh.cancel() | ||
| @@ -66,7 +75,7 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||||
| 66 | } | 75 | } | ||
| 67 | rh.session = session | 76 | rh.session = session | ||
| 68 | log.Info(). | 77 | log.Info(). | ||
| 69 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | 78 | + | Str("source_url", sourceURL). |
| 70 | Str("content_type", session.ContentType()). | 79 | Str("content_type", session.ContentType()). | ||
| 71 | Msg("cef: transcoding resource stream ready") | 80 | Msg("cef: transcoding resource stream ready") | ||
| 72 | callback.Cont() | 81 | callback.Cont() | ||
| @@ -76,8 +85,10 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa | |||||
| 76 | } | 85 | } | ||
| 77 | 86 | ||||
| 78 | // GetResponseHeaders sets the streaming response metadata. | 87 | // GetResponseHeaders sets the streaming response metadata. | ||
| 79 | - | func (rh *transcodingResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) { | 88 | + | func (rh *transcodingResourceHandler) GetResponseHeaders( |
| 80 | - | response.SetStatus(200) | 89 | + | response purecef.Response, responseLength unsafe.Pointer, _ uintptr, |
| 90 | + | ) { | |||
| 91 | + | response.SetStatus(httpStatusOK) | |||
| 81 | response.SetStatusText("OK") | 92 | response.SetStatusText("OK") | ||
| 82 | response.SetMimeType("video/webm") | 93 | response.SetMimeType("video/webm") | ||
| 83 | response.SetHeaderByName("Accept-Ranges", "none", 1) | 94 | response.SetHeaderByName("Accept-Ranges", "none", 1) | ||
| @@ -105,7 +116,7 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 105 | rh.totalBytes += int64(n) | 116 | rh.totalBytes += int64(n) | ||
| 106 | if rh.totalBytes == int64(n) { | 117 | if rh.totalBytes == int64(n) { | ||
| 107 | rh.logger().Info(). | 118 | rh.logger().Info(). | ||
| 108 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | 119 | + | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). |
| 109 | Int("first_chunk_bytes", n). | 120 | Int("first_chunk_bytes", n). | ||
| 110 | Msg("cef: transcoding resource stream produced first bytes") | 121 | Msg("cef: transcoding resource stream produced first bytes") | ||
| 111 | } | 122 | } | ||
| @@ -113,14 +124,15 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 113 | return 1 | 124 | return 1 | ||
| 114 | } | 125 | } | ||
| 115 | if err != nil { | 126 | if err != nil { | ||
| 127 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 116 | log := rh.logger().With(). | 128 | log := rh.logger().With(). | ||
| 117 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | 129 | + | Str("source_url", sourceURL). |
| 118 | Int64("total_bytes", rh.totalBytes). | 130 | Int64("total_bytes", rh.totalBytes). | ||
| 119 | Logger() | 131 | Logger() | ||
| 120 | switch { | 132 | switch { | ||
| 121 | - | case err == io.EOF && rh.totalBytes == 0: | 133 | + | case errors.Is(err, io.EOF) && rh.totalBytes == 0: |
| 122 | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | 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 | log.Debug().Msg("cef: transcoding resource stream reached EOF") | 136 | log.Debug().Msg("cef: transcoding resource stream reached EOF") | ||
| 125 | default: | 137 | default: | ||
| 126 | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | 138 | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | ||
| @@ -132,12 +144,18 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 132 | 144 | ||||
| 133 | // Cancel aborts the transcode session and releases resources. | 145 | // Cancel aborts the transcode session and releases resources. | ||
| 134 | func (rh *transcodingResourceHandler) Cancel() { | 146 | func (rh *transcodingResourceHandler) Cancel() { | ||
| 147 | + | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 135 | rh.logger().Debug(). | 148 | rh.logger().Debug(). | ||
| 136 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, 240)). | 149 | + | Str("source_url", sourceURL). |
| 137 | Int64("total_bytes", rh.totalBytes). | 150 | Int64("total_bytes", rh.totalBytes). | ||
| 138 | - | Msg("cef: transcoding resource stream cancelled") | 151 | + | Msg("cef: transcoding resource stream canceled") |
| 139 | if rh.session != nil { | 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 | if rh.cancel != nil { | 160 | if rh.cancel != nil { | ||
| 143 | rh.cancel() | 161 | rh.cancel() | ||
| @@ -178,7 +196,9 @@ type requestInfo struct { | |||||
| 178 | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | 196 | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | ||
| 179 | // proprietary video containers and HLS/DASH manifest entrypoints, then | 197 | // proprietary video containers and HLS/DASH manifest entrypoints, then | ||
| 180 | // restarts those requests through a GPU transcoding ResourceHandler. | 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 | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | 202 | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | ||
| 183 | transcoder: tc, | 203 | transcoder: tc, | ||
| 184 | ctxf: ctxf, | 204 | ctxf: ctxf, | ||
| @@ -189,7 +209,9 @@ func newTranscodingRequestHandler(tc port.MediaTranscoder, ctxf func() context.C | |||||
| 189 | // matches a proprietary video format or a streaming manifest entrypoint, it | 209 | // matches a proprietary video format or a streaming manifest entrypoint, it | ||
| 190 | // stores the request info and returns 1 to restart the request — at which | 210 | // stores the request info and returns 1 to restart the request — at which | ||
| 191 | // point GetResourceHandler will provide the transcoding handler. | 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 | if response == nil || request == nil { | 215 | if response == nil || request == nil { | ||
| 194 | return 0 | 216 | return 0 | ||
| 195 | } | 217 | } | ||
| @@ -202,7 +224,7 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure | |||||
| 202 | isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url)) | 224 | isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url)) | ||
| 203 | if isLikelyMediaResponse(url, mimeType) { | 225 | if isLikelyMediaResponse(url, mimeType) { | ||
| 204 | log.Debug(). | 226 | log.Debug(). | ||
| 205 | - | Str("url", logging.TruncateURL(url, 240)). | 227 | + | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). |
| 206 | Str("mime_type", mimeType). | 228 | Str("mime_type", mimeType). | ||
| 207 | Bool("proprietary_match", isProprietary). | 229 | Bool("proprietary_match", isProprietary). | ||
| 208 | Bool("already_open", isAlreadyOpen). | 230 | Bool("already_open", isAlreadyOpen). | ||
| @@ -225,7 +247,7 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure | |||||
| 225 | reason = "streaming-manifest" | 247 | reason = "streaming-manifest" | ||
| 226 | } | 248 | } | ||
| 227 | log.Info(). | 249 | log.Info(). | ||
| 228 | - | Str("url", logging.TruncateURL(url, 240)). | 250 | + | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). |
| 229 | Str("mime_type", mimeType). | 251 | Str("mime_type", mimeType). | ||
| 230 | Str("transcode_reason", reason). | 252 | Str("transcode_reason", reason). | ||
| 231 | Int("forwarded_header_count", len(info.headers)). | 253 | Int("forwarded_header_count", len(info.headers)). | ||
| @@ -236,7 +258,9 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure | |||||
| 236 | 258 | ||||
| 237 | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | 259 | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | ||
| 238 | // either identified eagerly by URL heuristics or marked by OnResourceResponse. | 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 | if request == nil { | 264 | if request == nil { | ||
| 241 | return nil | 265 | return nil | ||
| 242 | } | 266 | } | ||
| @@ -275,9 +299,11 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure | |||||
| 275 | } | 299 | } | ||
| 276 | } | 300 | } | ||
| 277 | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | 301 | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | ||
| 302 | + | shortURL := logging.TruncateURL(url, maxTranscodingURLLength) | |||
| 303 | + | shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength) | |||
| 278 | h.logger().Info(). | 304 | h.logger().Info(). | ||
| 279 | - | Str("url", logging.TruncateURL(url, 240)). | 305 | + | Str("url", shortURL). |
| 280 | - | Str("source_url", logging.TruncateURL(sourceURL, 240)). | 306 | + | Str("source_url", shortSourceURL). |
| 281 | Bool("eager_match", eager). | 307 | Bool("eager_match", eager). | ||
| 282 | Int("forwarded_header_count", len(info.headers)). | 308 | Int("forwarded_header_count", len(info.headers)). | ||
| 283 | Msg("cef: GetResourceHandler returning transcoding resource handler") | 309 | Msg("cef: GetResourceHandler returning transcoding resource handler") | ||
| @@ -293,22 +319,33 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure | |||||
| 293 | 319 | ||||
| 294 | // --- No-op methods required by purecef.ResourceRequestHandler --- | 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 | return nil | 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 | return purecef.ReturnValueRvContinue | 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 | return nil | 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 | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) { | 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 | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | 399 | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | ||
| 363 | if rh != nil && rh.logf != nil { | 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 | return &logger | 403 | return &logger | ||
| 366 | } | 404 | } | ||
| 367 | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | 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 | generation atomic.Uint64 | 80 | generation atomic.Uint64 | ||
| 81 | audioPlaying atomic.Bool | 81 | audioPlaying atomic.Bool | ||
| 82 | zoomFactor atomic.Value // float64, initialized to 1.0 | 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 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | 94 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | ||
| @@ -463,6 +472,7 @@ func (wv *WebView) Destroy() { | |||||
| 463 | if !wv.destroyed.CompareAndSwap(false, true) { | 472 | if !wv.destroyed.CompareAndSwap(false, true) { | ||
| 464 | return | 473 | return | ||
| 465 | } | 474 | } | ||
| 475 | + | wv.closeAudioStream() | |||
| 466 | wv.scheduleStopBeginFrameLoop() | 476 | wv.scheduleStopBeginFrameLoop() | ||
| 467 | wv.mu.RLock() | 477 | wv.mu.RLock() | ||
| 468 | host := wv.host | 478 | host := wv.host | ||
| @@ -705,3 +715,21 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||||
| 705 | wv.pendingCreate = nil | 715 | wv.pendingCreate = nil | ||
| 706 | return pc | 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 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | 6 | ||||
| 6 | purecef "github.com/bnema/purego-cef/cef" | 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 | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | 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 | log := logging.FromContext(h.wv.ctx).With(). | 333 | log := logging.FromContext(h.wv.ctx).With(). | ||
| 332 | Str("component", "cef-console"). | 334 | Str("component", "cef-console"). | ||
| 333 | Str("source", source). | 335 | Str("source", source). | ||
| @@ -460,8 +462,6 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E | |||||
| 460 | // OnBeforePopup intercepts popup requests (target="_blank", window.open). | 462 | // OnBeforePopup intercepts popup requests (target="_blank", window.open). | ||
| 461 | // CEF OSR cannot create popup windows, so we fire the OnCreate callback | 463 | // CEF OSR cannot create popup windows, so we fire the OnCreate callback | ||
| 462 | // to let the coordinator open the link in a new stacked pane. | 464 | // to let the coordinator open the link in a new stacked pane. | ||
| 463 | - | // | |||
| 464 | - | //nolint:gocritic // signature imposed by purecef.LifeSpanHandler interface | |||
| 465 | func (h *handlerSet) OnBeforePopup( | 465 | func (h *handlerSet) OnBeforePopup( | ||
| 466 | _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string, | 466 | _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string, | ||
| 467 | _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | 467 | _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures, | ||
| @@ -493,7 +493,6 @@ func (h *handlerSet) OnBeforePopup( | |||||
| 493 | 493 | ||||
| 494 | func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {} | 494 | func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {} | ||
| 495 | 495 | ||||
| 496 | - | //nolint:gocritic // signature imposed by purecef.LifeSpanHandler interface | |||
| 497 | func (h *handlerSet) OnBeforeDevToolsPopup( | 496 | func (h *handlerSet) OnBeforeDevToolsPopup( | ||
| 498 | _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client, | 497 | _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client, | ||
| 499 | _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool, | 498 | _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool, | ||
| @@ -600,7 +599,7 @@ func (h *handlerSet) GetResourceRequestHandler( | |||||
| 600 | *disableDefaultHandling = 1 | 599 | *disableDefaultHandling = 1 | ||
| 601 | if h.wv != nil && h.wv.ctx != nil { | 600 | if h.wv != nil && h.wv.ctx != nil { | ||
| 602 | logging.FromContext(h.wv.ctx).Info(). | 601 | logging.FromContext(h.wv.ctx).Info(). | ||
| 603 | - | Str("url", logging.TruncateURL(request.GetURL(), 240)). | 602 | + | Str("url", logging.TruncateURL(request.GetURL(), maxTranscodingURLLength)). |
| 604 | Msg("cef: disabled default handling for eager transcode candidate") | 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 | return 1 // proceed with defaults | 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 | h.wv.setAudioPlaying(true) | 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 | func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) { | 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 | h.wv.setAudioPlaying(false) | 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 | h.wv.setAudioPlaying(false) | 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 | CEF: CEFEngineConfig{ | 161 | CEF: CEFEngineConfig{ | ||
| 162 | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | 162 | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | ||
| 163 | LogFile: "", | 163 | LogFile: "", | ||
| 164 | + | EnableAudioHandler: true, | |||
| 164 | }, | 165 | }, | ||
| 165 | WebKit: WebKitEngineConfig{ | 166 | WebKit: WebKitEngineConfig{ | ||
| 166 | ITPEnabled: true, | 167 | ITPEnabled: true, | ||
M internal/infrastructure/config/defaults_test.go +1 -1
| @@ -22,7 +22,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 22 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | 22 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | ||
| 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | ||
| 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | 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 | assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler) | 26 | assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler) | ||
| 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | ||
| 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | ||
M internal/infrastructure/config/engine.go +12 -12
| @@ -7,6 +7,17 @@ const ( | |||||
| 7 | EngineTypeCEF = "cef" | 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 | // ResolveEngineType returns the effective engine type from config + env override. | 21 | // ResolveEngineType returns the effective engine type from config + env override. | ||
| 11 | // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE | 22 | // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE | ||
| 12 | // to override the configured value for smoke testing. | 23 | // to override the configured value for smoke testing. | ||
| @@ -21,17 +32,6 @@ func (e *EngineConfig) ResolveEngineType() string { | |||||
| 21 | return engineType | 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 | // WebKitEngineConfig holds WebKit-specific engine options. | 35 | // WebKitEngineConfig holds WebKit-specific engine options. | ||
| 36 | type WebKitEngineConfig struct { | 36 | type WebKitEngineConfig struct { | ||
| 37 | // Skia rendering threads | 37 | // Skia rendering threads | ||
| @@ -96,7 +96,7 @@ type CEFEngineConfig struct { | |||||
| 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | ||
| 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | ||
| 98 | // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge. | 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 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | 100 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | ||
| 101 | TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"` | 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 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | 10 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | const cefLogSeverityDisabled = 99 | |||
| 14 | + | ||||
| 13 | // validateConfig performs comprehensive validation of configuration values | 15 | // validateConfig performs comprehensive validation of configuration values | ||
| 14 | func validateConfig(config *Config) error { | 16 | func validateConfig(config *Config) error { | ||
| 15 | var validationErrors []string | 17 | var validationErrors []string | ||
| @@ -329,10 +331,11 @@ func validateCEF(config *Config) []string { | |||||
| 329 | var validationErrors []string | 331 | var validationErrors []string | ||
| 330 | 332 | ||||
| 331 | switch config.Engine.CEF.LogSeverity { | 333 | switch config.Engine.CEF.LogSeverity { | ||
| 332 | - | case 0, 1, 2, 3, 4, 99: | 334 | + | case 0, 1, 2, 3, 4, cefLogSeverityDisabled: |
| 333 | default: | 335 | default: | ||
| 334 | validationErrors = append(validationErrors, fmt.Sprintf( | 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 | config.Engine.CEF.LogSeverity, | 339 | config.Engine.CEF.LogSeverity, | ||
| 337 | )) | 340 | )) | ||
| 338 | } | 341 | } | ||
M internal/infrastructure/config/writer_test.go +10 -6
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -48,17 +49,20 @@ func TestWriteConfigOrdered(t *testing.T) { | |||||
| 48 | } | 49 | } | ||
| 49 | // Match "enabled = true" specifically within the [transcoding] section to | 50 | // Match "enabled = true" specifically within the [transcoding] section to | ||
| 50 | // avoid false positives from other sections that may also have an enabled key. | 51 | // avoid false positives from other sections that may also have an enabled key. | ||
| 51 | - | transcodingIdx := strings.Index(string(content), "[transcoding]") | 52 | + | transcodingIdx := bytes.Index(content, []byte("[transcoding]")) |
| 52 | - | afterTranscoding := string(content)[transcodingIdx:] | 53 | + | if transcodingIdx < 0 { |
| 53 | - | nextSection := strings.Index(afterTranscoding[1:], "\n[") | 54 | + | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) |
| 54 | - | var transcodingBlock string | 55 | + | } |
| 56 | + | afterTranscoding := content[transcodingIdx:] | |||
| 57 | + | nextSection := bytes.Index(afterTranscoding[1:], []byte("\n[")) | |||
| 58 | + | var transcodingBlock []byte | |||
| 55 | if nextSection >= 0 { | 59 | if nextSection >= 0 { | ||
| 56 | transcodingBlock = afterTranscoding[:nextSection+1] | 60 | transcodingBlock = afterTranscoding[:nextSection+1] | ||
| 57 | } else { | 61 | } else { | ||
| 58 | transcodingBlock = afterTranscoding | 62 | transcodingBlock = afterTranscoding | ||
| 59 | } | 63 | } | ||
| 60 | - | if !strings.Contains(transcodingBlock, "enabled = true") { | 64 | + | if !bytes.Contains(transcodingBlock, []byte("enabled = true")) { |
| 61 | - | t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", transcodingBlock) | 65 | + | t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", string(transcodingBlock)) |
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | t.Logf("Found %d sections, all sorted", len(sections)) | 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 | Return(nil, nil) // nil filter triggers fallback to download | 51 | Return(nil, nil) // nil filter triggers fallback to download | ||
| 52 | 52 | ||||
| 53 | // Fallback: download will be triggered since Load returned nil filter | 53 | // Fallback: download will be triggered since Load returned nil filter | ||
| 54 | + | mockDownloader.EXPECT(). | |||
| 55 | + | GetCachedFilterPaths(). | |||
| 56 | + | Return(nil) | |||
| 57 | + | ||||
| 54 | mockDownloader.EXPECT(). | 58 | mockDownloader.EXPECT(). | ||
| 55 | DownloadFilters(mock.Anything, mock.Anything). | 59 | DownloadFilters(mock.Anything, mock.Anything). | ||
| 56 | Return([]string{jsonFile}, nil) | 60 | Return([]string{jsonFile}, nil) | ||
| @@ -140,6 +144,10 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||||
| 140 | Return([]string{}, nil) | 144 | Return([]string{}, nil) | ||
| 141 | 145 | ||||
| 142 | // Download should be triggered | 146 | // Download should be triggered | ||
| 147 | + | mockDownloader.EXPECT(). | |||
| 148 | + | GetCachedFilterPaths(). | |||
| 149 | + | Return(nil) | |||
| 150 | + | ||||
| 143 | mockDownloader.EXPECT(). | 151 | mockDownloader.EXPECT(). | ||
| 144 | DownloadFilters(mock.Anything, mock.Anything). | 152 | DownloadFilters(mock.Anything, mock.Anything). | ||
| 145 | Return([]string{jsonFile}, nil) | 153 | Return([]string{jsonFile}, nil) | ||
| @@ -455,6 +463,9 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||||
| 455 | mockStore.EXPECT(). | 463 | mockStore.EXPECT(). | ||
| 456 | Load(mock.Anything, "ublock-combined-0"). | 464 | Load(mock.Anything, "ublock-combined-0"). | ||
| 457 | Return(nil, nil) // nil triggers download fallback | 465 | Return(nil, nil) // nil triggers download fallback | ||
| 466 | + | mockDownloader.EXPECT(). | |||
| 467 | + | GetCachedFilterPaths(). | |||
| 468 | + | Return(nil) | |||
| 458 | mockDownloader.EXPECT(). | 469 | mockDownloader.EXPECT(). | ||
| 459 | DownloadFilters(mock.Anything, mock.Anything). | 470 | DownloadFilters(mock.Anything, mock.Anything). | ||
| 460 | Return([]string{jsonFile}, nil) | 471 | Return([]string{jsonFile}, nil) | ||
| @@ -519,6 +530,9 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) { | |||||
| 519 | mockStore.EXPECT(). | 530 | mockStore.EXPECT(). | ||
| 520 | FetchIdentifiers(mock.Anything). | 531 | FetchIdentifiers(mock.Anything). | ||
| 521 | Return([]string{}, nil) | 532 | Return([]string{}, nil) | ||
| 533 | + | mockDownloader.EXPECT(). | |||
| 534 | + | GetCachedFilterPaths(). | |||
| 535 | + | Return(nil) | |||
| 522 | 536 | ||||
| 523 | mockDownloader.EXPECT(). | 537 | mockDownloader.EXPECT(). | ||
| 524 | DownloadFilters(mock.Anything, mock.Anything). | 538 | DownloadFilters(mock.Anything, mock.Anything). | ||
M internal/infrastructure/transcoder/audio.go +88 -73
| @@ -20,6 +20,35 @@ const opusBitrate = 128000 | |||||
| 20 | // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration. | 20 | // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration. | ||
| 21 | const opusFrameSize = 960 | 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 | // audioTranscoder decodes an input audio stream (typically AAC) and | 52 | // audioTranscoder decodes an input audio stream (typically AAC) and | ||
| 24 | // re-encodes it to Opus, performing sample rate conversion if needed. | 53 | // re-encodes it to Opus, performing sample rate conversion if needed. | ||
| 25 | type audioTranscoder struct { | 54 | type audioTranscoder struct { | ||
| @@ -38,6 +67,11 @@ type audioTranscoder struct { | |||||
| 38 | // outFmtCtx is the AVFormatContext* for the output container (used to | 67 | // outFmtCtx is the AVFormatContext* for the output container (used to | ||
| 39 | // add the output stream). inStreamIdx is the input stream index. | 68 | // add the output stream). inStreamIdx is the input stream index. | ||
| 40 | func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | 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 | // --- Decoder setup --- | 75 | // --- Decoder setup --- | ||
| 42 | codecID := ffmpeg.CodecParCodecID(inCodecPar) | 76 | codecID := ffmpeg.CodecParCodecID(inCodecPar) | ||
| 43 | dec := ffmpeg.CodecFindDecoder(codecID) | 77 | dec := ffmpeg.CodecFindDecoder(codecID) | ||
| @@ -51,25 +85,25 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) ( | |||||
| 51 | } | 85 | } | ||
| 52 | 86 | ||||
| 53 | if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 { | 87 | if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 { | ||
| 54 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 88 | + | freeCodecContext(&decCtx) |
| 55 | return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret) | 89 | return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret) | ||
| 56 | } | 90 | } | ||
| 57 | 91 | ||||
| 58 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | 92 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | ||
| 59 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 93 | + | freeCodecContext(&decCtx) |
| 60 | return nil, fmt.Errorf("failed to open audio decoder: %d", ret) | 94 | return nil, fmt.Errorf("failed to open audio decoder: %d", ret) | ||
| 61 | } | 95 | } | ||
| 62 | 96 | ||||
| 63 | // --- Encoder setup (libopus) --- | 97 | // --- Encoder setup (libopus) --- | ||
| 64 | enc := ffmpeg.CodecFindEncoderByName("libopus") | 98 | enc := ffmpeg.CodecFindEncoderByName("libopus") | ||
| 65 | if enc == nil { | 99 | if enc == nil { | ||
| 66 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 100 | + | freeCodecContext(&decCtx) |
| 67 | return nil, errors.New("libopus encoder not found") | 101 | return nil, errors.New("libopus encoder not found") | ||
| 68 | } | 102 | } | ||
| 69 | 103 | ||||
| 70 | encCtx := ffmpeg.CodecAllocContext3(enc) | 104 | encCtx := ffmpeg.CodecAllocContext3(enc) | ||
| 71 | if encCtx == nil { | 105 | if encCtx == nil { | ||
| 72 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 106 | + | freeCodecContext(&decCtx) |
| 73 | return nil, errors.New("failed to allocate audio encoder context") | 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 | // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus. | 122 | // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus. | ||
| 89 | 123 | ||||
| 90 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | 124 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | ||
| 91 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 125 | + | freeCodecContext(&encCtx) |
| 92 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 126 | + | freeCodecContext(&decCtx) |
| 93 | return nil, fmt.Errorf("failed to open audio encoder: %d", ret) | 127 | return nil, fmt.Errorf("failed to open audio encoder: %d", ret) | ||
| 94 | } | 128 | } | ||
| 95 | 129 | ||||
| 96 | // --- Add output stream --- | 130 | // --- Add output stream --- | ||
| 97 | outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil) | 131 | outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil) | ||
| 98 | if outStreamPtr == nil { | 132 | if outStreamPtr == nil { | ||
| 99 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 133 | + | freeCodecContext(&encCtx) |
| 100 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 134 | + | freeCodecContext(&decCtx) |
| 101 | return nil, errors.New("failed to create output audio stream") | 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 | // Copy encoder parameters to the output stream's codecpar. | 141 | // Copy encoder parameters to the output stream's codecpar. | ||
| 108 | if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 { | 142 | if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 { | ||
| 109 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 143 | + | freeCodecContext(&encCtx) |
| 110 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 144 | + | freeCodecContext(&decCtx) |
| 111 | return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret) | 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 | if needResample { | 156 | if needResample { | ||
| 123 | swr = ffmpeg.SwrAlloc() | 157 | swr = ffmpeg.SwrAlloc() | ||
| 124 | if swr == nil { | 158 | if swr == nil { | ||
| 125 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 159 | + | freeCodecContext(&encCtx) |
| 126 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 160 | + | freeCodecContext(&decCtx) |
| 127 | return nil, errors.New("failed to allocate resampler") | 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 | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | 176 | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | ||
| 143 | 177 | ||||
| 144 | if ret := ffmpeg.SwrInit(swr); ret < 0 { | 178 | if ret := ffmpeg.SwrInit(swr); ret < 0 { | ||
| 145 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | 179 | + | freeSwrContext(&swr) |
| 146 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 180 | + | freeCodecContext(&encCtx) |
| 147 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 181 | + | freeCodecContext(&decCtx) |
| 148 | return nil, fmt.Errorf("failed to init resampler: %d", ret) | 182 | return nil, fmt.Errorf("failed to init resampler: %d", ret) | ||
| 149 | } | 183 | } | ||
| 150 | 184 | ||||
| 151 | // Allocate a reusable output frame for resampled data. | 185 | // Allocate a reusable output frame for resampled data. | ||
| 152 | resampledFrame = ffmpeg.FrameAlloc() | 186 | resampledFrame = ffmpeg.FrameAlloc() | ||
| 153 | if resampledFrame == nil { | 187 | if resampledFrame == nil { | ||
| 154 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | 188 | + | freeSwrContext(&swr) |
| 155 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 189 | + | freeCodecContext(&encCtx) |
| 156 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 190 | + | freeCodecContext(&decCtx) |
| 157 | return nil, errors.New("failed to allocate resampled frame") | 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 | decFrame := ffmpeg.FrameAlloc() | 201 | decFrame := ffmpeg.FrameAlloc() | ||
| 168 | if decFrame == nil { | 202 | if decFrame == nil { | ||
| 169 | if resampledFrame != nil { | 203 | if resampledFrame != nil { | ||
| 170 | - | ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame)) | 204 | + | freeFrame(&resampledFrame) |
| 171 | } | 205 | } | ||
| 172 | if swr != nil { | 206 | if swr != nil { | ||
| 173 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | 207 | + | freeSwrContext(&swr) |
| 174 | } | 208 | } | ||
| 175 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 209 | + | freeCodecContext(&encCtx) |
| 176 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 210 | + | freeCodecContext(&decCtx) |
| 177 | return nil, errors.New("failed to allocate audio decode frame") | 211 | return nil, errors.New("failed to allocate audio decode frame") | ||
| 178 | } | 212 | } | ||
| 179 | 213 | ||||
| 180 | encPkt := ffmpeg.PacketAlloc() | 214 | encPkt := ffmpeg.PacketAlloc() | ||
| 181 | if encPkt == nil { | 215 | if encPkt == nil { | ||
| 182 | - | ffmpeg.FrameFree(unsafe.Pointer(&decFrame)) | 216 | + | freeFrame(&decFrame) |
| 183 | if resampledFrame != nil { | 217 | if resampledFrame != nil { | ||
| 184 | - | ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame)) | 218 | + | freeFrame(&resampledFrame) |
| 185 | } | 219 | } | ||
| 186 | if swr != nil { | 220 | if swr != nil { | ||
| 187 | - | ffmpeg.SwrFree(unsafe.Pointer(&swr)) | 221 | + | freeSwrContext(&swr) |
| 188 | } | 222 | } | ||
| 189 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 223 | + | freeCodecContext(&encCtx) |
| 190 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 224 | + | freeCodecContext(&decCtx) |
| 191 | return nil, errors.New("failed to allocate audio encode packet") | 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 | // to Opus, and writes the resulting packets to outFmtCtx. | 241 | // to Opus, and writes the resulting packets to outFmtCtx. | ||
| 208 | func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | 242 | func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | ||
| 209 | // Send packet to decoder. | 243 | // Send packet to decoder. | ||
| 210 | - | ret := ffmpeg.CodecSendPacket(a.decoder, pkt) | 244 | + | sendRet := ffmpeg.CodecSendPacket(a.decoder, pkt) |
| 211 | - | if ret < 0 { | 245 | + | if sendRet < 0 { |
| 212 | - | return fmt.Errorf("audio decode send packet: %d", ret) | 246 | + | return fmt.Errorf("audio decode send packet: %d", sendRet) |
| 213 | } | 247 | } | ||
| 214 | 248 | ||||
| 215 | frame := a.decFrame | 249 | frame := a.decFrame | ||
| 216 | encPkt := a.encPkt | 250 | encPkt := a.encPkt | ||
| 217 | 251 | ||||
| 218 | for { | 252 | for { | ||
| 219 | - | ret = ffmpeg.CodecReceiveFrame(a.decoder, frame) | 253 | + | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) |
| 220 | - | if ret < 0 { | 254 | + | if recvRet < 0 { |
| 221 | // EAGAIN or EOF — no more frames from this packet. | 255 | // EAGAIN or EOF — no more frames from this packet. | ||
| 222 | break | 256 | break | ||
| 223 | } | 257 | } | ||
| @@ -227,27 +261,16 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||||
| 227 | if a.resampler != nil { | 261 | if a.resampler != nil { | ||
| 228 | encFrame = a.frame | 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 | inSamples := frameNbSamples(frame) | 264 | inSamples := frameNbSamples(frame) | ||
| 238 | 265 | ||||
| 239 | // Allocate buffer for output frame if needed. | 266 | // Allocate buffer for output frame if needed. | ||
| 240 | frameSetNbSamples(a.frame, opusFrameSize) | 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 | ffmpeg.FrameUnref(frame) | 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( | 273 | + | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) |
| 247 | - | a.resampler, | |||
| 248 | - | unsafe.Pointer(outData), opusFrameSize, | |||
| 249 | - | unsafe.Pointer(inData), inSamples, | |||
| 250 | - | ) | |||
| 251 | if converted < 0 { | 274 | if converted < 0 { | ||
| 252 | ffmpeg.FrameUnref(frame) | 275 | ffmpeg.FrameUnref(frame) | ||
| 253 | return fmt.Errorf("audio resample failed: %d", converted) | 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 | // Send frame to encoder. | 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 | ffmpeg.FrameUnref(frame) | 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 | // Receive encoded packets. | 295 | // Receive encoded packets. | ||
| 273 | for { | 296 | for { | ||
| 274 | - | ret = ffmpeg.CodecReceivePacket(a.encoder, encPkt) | 297 | + | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) |
| 275 | - | if ret < 0 { | 298 | + | if recvPktRet < 0 { |
| 276 | break | 299 | break | ||
| 277 | } | 300 | } | ||
| 278 | 301 | ||||
| @@ -309,32 +332,24 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 309 | 332 | ||||
| 310 | // Drain decoded frames. | 333 | // Drain decoded frames. | ||
| 311 | for { | 334 | for { | ||
| 312 | - | ret := ffmpeg.CodecReceiveFrame(a.decoder, frame) | 335 | + | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) |
| 313 | - | if ret < 0 { | 336 | + | if recvRet < 0 { |
| 314 | break | 337 | break | ||
| 315 | } | 338 | } | ||
| 316 | 339 | ||||
| 317 | encFrame := frame | 340 | encFrame := frame | ||
| 318 | if a.resampler != nil { | 341 | if a.resampler != nil { | ||
| 319 | encFrame = a.frame | 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 | inSamples := frameNbSamples(frame) | 343 | inSamples := frameNbSamples(frame) | ||
| 325 | 344 | ||||
| 326 | frameSetNbSamples(a.frame, opusFrameSize) | 345 | frameSetNbSamples(a.frame, opusFrameSize) | ||
| 327 | - | if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 { | 346 | + | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { |
| 328 | - | logger.Warn().Int32("ret", ret).Msg("failed to allocate resampled frame buffer during flush") | 347 | + | logger.Warn().Int32("ret", bufferRet).Msg("failed to allocate resampled frame buffer during flush") |
| 329 | ffmpeg.FrameUnref(frame) | 348 | ffmpeg.FrameUnref(frame) | ||
| 330 | continue | 349 | continue | ||
| 331 | } | 350 | } | ||
| 332 | 351 | ||||
| 333 | - | converted := ffmpeg.SwrConvert( | 352 | + | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) |
| 334 | - | a.resampler, | |||
| 335 | - | unsafe.Pointer(outData), opusFrameSize, | |||
| 336 | - | unsafe.Pointer(inData), inSamples, | |||
| 337 | - | ) | |||
| 338 | if converted < 0 { | 353 | if converted < 0 { | ||
| 339 | logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush") | 354 | logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush") | ||
| 340 | ffmpeg.FrameUnref(frame) | 355 | ffmpeg.FrameUnref(frame) | ||
| @@ -346,8 +361,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 346 | ffmpeg.CodecSendFrame(a.encoder, encFrame) | 361 | ffmpeg.CodecSendFrame(a.encoder, encFrame) | ||
| 347 | 362 | ||||
| 348 | for { | 363 | for { | ||
| 349 | - | ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | 364 | + | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) |
| 350 | - | if ret < 0 { | 365 | + | if recvPktRet < 0 { |
| 351 | break | 366 | break | ||
| 352 | } | 367 | } | ||
| 353 | pktSetStreamIndex(encPkt, int32(a.outStream)) | 368 | pktSetStreamIndex(encPkt, int32(a.outStream)) | ||
| @@ -364,8 +379,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 364 | // Flush encoder by sending NULL frame. | 379 | // Flush encoder by sending NULL frame. | ||
| 365 | ffmpeg.CodecSendFrame(a.encoder, nil) | 380 | ffmpeg.CodecSendFrame(a.encoder, nil) | ||
| 366 | for { | 381 | for { | ||
| 367 | - | ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | 382 | + | flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) |
| 368 | - | if ret < 0 { | 383 | + | if flushPktRet < 0 { |
| 369 | break | 384 | break | ||
| 370 | } | 385 | } | ||
| 371 | pktSetStreamIndex(encPkt, int32(a.outStream)) | 386 | pktSetStreamIndex(encPkt, int32(a.outStream)) | ||
| @@ -380,21 +395,21 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) | |||||
| 380 | // close frees all FFmpeg resources owned by the audio transcoder. | 395 | // close frees all FFmpeg resources owned by the audio transcoder. | ||
| 381 | func (a *audioTranscoder) close() { | 396 | func (a *audioTranscoder) close() { | ||
| 382 | if a.encPkt != nil { | 397 | if a.encPkt != nil { | ||
| 383 | - | ffmpeg.PacketFree(unsafe.Pointer(&a.encPkt)) | 398 | + | freePacket(&a.encPkt) |
| 384 | } | 399 | } | ||
| 385 | if a.decFrame != nil { | 400 | if a.decFrame != nil { | ||
| 386 | - | ffmpeg.FrameFree(unsafe.Pointer(&a.decFrame)) | 401 | + | freeFrame(&a.decFrame) |
| 387 | } | 402 | } | ||
| 388 | if a.frame != nil { | 403 | if a.frame != nil { | ||
| 389 | - | ffmpeg.FrameFree(unsafe.Pointer(&a.frame)) | 404 | + | freeFrame(&a.frame) |
| 390 | } | 405 | } | ||
| 391 | if a.resampler != nil { | 406 | if a.resampler != nil { | ||
| 392 | - | ffmpeg.SwrFree(unsafe.Pointer(&a.resampler)) | 407 | + | freeSwrContext(&a.resampler) |
| 393 | } | 408 | } | ||
| 394 | if a.encoder != nil { | 409 | if a.encoder != nil { | ||
| 395 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&a.encoder)) | 410 | + | freeCodecContext(&a.encoder) |
| 396 | } | 411 | } | ||
| 397 | if a.decoder != nil { | 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 | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | 113 | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | ||
| 114 | // that should be intercepted and resolved to an underlying source URL. | 114 | // that should be intercepted and resolved to an underlying source URL. | ||
| 115 | func IsSyntheticTranscodeURL(rawURL string) bool { | 115 | func IsSyntheticTranscodeURL(rawURL string) bool { | ||
| 116 | - | _, _, _, ok := ParseSyntheticTranscodeURL(rawURL) | 116 | + | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) |
| 117 | + | _ = sourceURL | |||
| 118 | + | _ = referer | |||
| 119 | + | _ = origin | |||
| 117 | return ok | 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 | const ( | 22 | const ( | ||
| 23 | - | offsetPktPts = 8 | |||
| 24 | - | offsetPktDts = 16 | |||
| 25 | offsetPktStreamIndex = 36 | 23 | offsetPktStreamIndex = 36 | ||
| 26 | ) | 24 | ) | ||
| 27 | 25 | ||||
| @@ -91,7 +89,7 @@ func frameSetFormat(f unsafe.Pointer, v int32) { | |||||
| 91 | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | 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 | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | 93 | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | ||
| 96 | } | 94 | } | ||
| 97 | 95 | ||||
| @@ -103,10 +101,6 @@ func frameSetPts(f unsafe.Pointer, v int64) { | |||||
| 103 | *(*int64)(unsafe.Add(f, offsetFramePts)) = v | 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 | func frameSetSampleRate(f unsafe.Pointer, v int32) { | 104 | func frameSetSampleRate(f unsafe.Pointer, v int32) { | ||
| 111 | *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v | 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 | const ( | 112 | const ( | ||
| 119 | - | offsetCodecCtxFlags = 64 | |||
| 120 | offsetCodecCtxGopSize = 332 | 113 | offsetCodecCtxGopSize = 332 | ||
| 121 | offsetCodecCtxBitRate = 56 | 114 | offsetCodecCtxBitRate = 56 | ||
| 122 | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) | 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 | func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) { | 118 | func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) { | ||
| 138 | *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v | 119 | *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v | ||
| 139 | } | 120 | } | ||
| @@ -144,13 +125,15 @@ func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |||||
| 144 | 125 | ||||
| 145 | // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst. | 126 | // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst. | ||
| 146 | // Both must point to the ch_layout field within their respective AVCodecContext. | 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 | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | 130 | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | ||
| 148 | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | 131 | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | ||
| 149 | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | 132 | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | ||
| 150 | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | 133 | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | ||
| 151 | copy( | 134 | copy( | ||
| 152 | - | unsafe.Slice((*byte)(dstLayout), 24), | 135 | + | unsafe.Slice((*byte)(dstLayout), channelLayoutSize), |
| 153 | - | unsafe.Slice((*byte)(srcLayout), 24), | 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 | const ( | 146 | const ( | ||
| 147 | + | channelLayoutSize = 24 | |||
| 148 | + | ||||
| 164 | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | 149 | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | ||
| 165 | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | 150 | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | ||
| 166 | ) | 151 | ) | ||
| 167 | 152 | ||||
| 168 | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | 153 | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | ||
| 169 | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | 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 | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | 157 | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | ||
| 171 | dst := unsafe.Add(swr, swrOffset) | 158 | dst := unsafe.Add(swr, swrOffset) | ||
| 172 | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | 159 | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | ||
| 173 | copy( | 160 | copy( | ||
| 174 | - | unsafe.Slice((*byte)(dst), 24), | 161 | + | unsafe.Slice((*byte)(dst), channelLayoutSize), |
| 175 | - | unsafe.Slice((*byte)(src), 24), | 162 | + | unsafe.Slice((*byte)(src), channelLayoutSize), |
| 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), | |||
| 200 | ) | 163 | ) | ||
| 201 | } | 164 | } | ||
M internal/infrastructure/transcoder/hwprobe.go +5 -3
| @@ -16,6 +16,8 @@ var usableEncoders = map[string]bool{ | |||||
| 16 | "av1_nvenc": true, | 16 | "av1_nvenc": true, | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | + | const hwAPINameVAAPI = "vaapi" | |||
| 20 | + | ||||
| 19 | // isUsableEncoder returns true only for hardware encoders whose output | 21 | // isUsableEncoder returns true only for hardware encoders whose output | ||
| 20 | // codec is open and decodable by CEF (AV1, VP9). | 22 | // codec is open and decodable by CEF (AV1, VP9). | ||
| 21 | func isUsableEncoder(name string) bool { | 23 | func isUsableEncoder(name string) bool { | ||
| @@ -31,7 +33,7 @@ type hwProfile struct { | |||||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 33 | var vaAPIProfile = hwProfile{ | 35 | var vaAPIProfile = hwProfile{ | ||
| 34 | - | api: "vaapi", | 36 | + | api: hwAPINameVAAPI, |
| 35 | deviceType: int32(ffmpeg.HwdeviceTypeVaapi), | 37 | deviceType: int32(ffmpeg.HwdeviceTypeVaapi), | ||
| 36 | encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"}, | 38 | encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"}, | ||
| 37 | decoders: []string{"h264_vaapi", "hevc_vaapi"}, | 39 | decoders: []string{"h264_vaapi", "hevc_vaapi"}, | ||
| @@ -59,11 +61,11 @@ func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities { | |||||
| 59 | 61 | ||||
| 60 | var profiles []hwProfile | 62 | var profiles []hwProfile | ||
| 61 | switch hwaccelPref { | 63 | switch hwaccelPref { | ||
| 62 | - | case "vaapi": | 64 | + | case hwAPINameVAAPI: |
| 63 | profiles = []hwProfile{vaAPIProfile} | 65 | profiles = []hwProfile{vaAPIProfile} | ||
| 64 | case "nvenc": | 66 | case "nvenc": | ||
| 65 | profiles = []hwProfile{nvencProfile} | 67 | profiles = []hwProfile{nvencProfile} | ||
| 66 | - | default: // "auto" or unrecognised | 68 | + | default: // "auto" or unrecognized |
| 67 | profiles = []hwProfile{vaAPIProfile, nvencProfile} | 69 | profiles = []hwProfile{vaAPIProfile, nvencProfile} | ||
| 68 | } | 70 | } | ||
| 69 | 71 | ||||
M internal/infrastructure/transcoder/pipeline.go +78 -32
| @@ -24,6 +24,11 @@ import ( | |||||
| 24 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | 24 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | ||
| 25 | const avioBufSize = 32 * 1024 | 25 | const avioBufSize = 32 * 1024 | ||
| 26 | 26 | ||||
| 27 | + | const ( | |||
| 28 | + | vaapiInitialPoolSize = 20 | |||
| 29 | + | minVideoBitrate = 500_000 | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 27 | // pipeline orchestrates a single GPU transcode session. It reads from | 32 | // pipeline orchestrates a single GPU transcode session. It reads from | ||
| 28 | // an HTTP source, decodes with a hardware decoder (or software fallback), | 33 | // an HTTP source, decodes with a hardware decoder (or software fallback), | ||
| 29 | // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to | 34 | // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to | ||
| @@ -36,7 +41,6 @@ type pipeline struct { | |||||
| 36 | headers map[string]string | 41 | headers map[string]string | ||
| 37 | quality string | 42 | quality string | ||
| 38 | pw *io.PipeWriter | 43 | pw *io.PipeWriter | ||
| 39 | - | cancel context.CancelFunc | |||
| 40 | logger zerolog.Logger | 44 | logger zerolog.Logger | ||
| 41 | 45 | ||||
| 42 | // Prevent GC of callback closures and uintptrs. | 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 | return &pipeline{ | 71 | return &pipeline{ | ||
| 60 | sessionID: sessionID, | 72 | sessionID: sessionID, | ||
| 61 | hwCaps: hwCaps, | 73 | hwCaps: hwCaps, | ||
| @@ -77,23 +89,28 @@ func (p *pipeline) run(ctx context.Context) { | |||||
| 77 | Str("source_url", p.sourceURL). | 89 | Str("source_url", p.sourceURL). | ||
| 78 | Logger() | 90 | Logger() | ||
| 79 | if errors.Is(err, context.Canceled) { | 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 | } else { | 93 | } else { | ||
| 82 | log.Error().Err(err).Msg("transcode pipeline failed") | 94 | log.Error().Err(err).Msg("transcode pipeline failed") | ||
| 83 | } | 95 | } | ||
| 84 | - | p.pw.CloseWithError(err) | 96 | + | _ = p.pw.CloseWithError(err) |
| 85 | } else { | 97 | } else { | ||
| 86 | p.logger.Debug(). | 98 | p.logger.Debug(). | ||
| 87 | Str("session_id", p.sessionID). | 99 | Str("session_id", p.sessionID). | ||
| 88 | Str("source_url", p.sourceURL). | 100 | Str("source_url", p.sourceURL). | ||
| 89 | Msg("transcode pipeline completed") | 101 | Msg("transcode pipeline completed") | ||
| 90 | - | p.pw.Close() | 102 | + | _ = p.pw.Close() |
| 91 | } | 103 | } | ||
| 92 | } | 104 | } | ||
| 93 | 105 | ||||
| 94 | // doRun contains the actual pipeline logic. Errors are returned, and | 106 | // doRun contains the actual pipeline logic. Errors are returned, and | ||
| 95 | // the caller (run) closes the pipe writer accordingly. | 107 | // the caller (run) closes the pipe writer accordingly. | ||
| 96 | func (p *pipeline) doRun(ctx context.Context) error { | 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 | // --- Step 1-3: Open the input using the appropriate strategy --- | 114 | // --- Step 1-3: Open the input using the appropriate strategy --- | ||
| 98 | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | 115 | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | ||
| 99 | if err != nil { | 116 | if err != nil { | ||
| @@ -121,6 +138,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 121 | if err != nil { | 138 | if err != nil { | ||
| 122 | return fmt.Errorf("open video decoder: %w", err) | 139 | return fmt.Errorf("open video decoder: %w", err) | ||
| 123 | } | 140 | } | ||
| 141 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 124 | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx)) | 142 | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx)) | ||
| 125 | 143 | ||||
| 126 | // --- Step 7-8: Open video encoder with HW accel --- | 144 | // --- Step 7-8: Open video encoder with HW accel --- | ||
| @@ -131,10 +149,12 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 131 | if hwDeviceCtx != nil { | 149 | if hwDeviceCtx != nil { | ||
| 132 | defer ffmpeg.BufferUnref(&hwDeviceCtx) | 150 | defer ffmpeg.BufferUnref(&hwDeviceCtx) | ||
| 133 | } | 151 | } | ||
| 152 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 134 | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx)) | 153 | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx)) | ||
| 135 | 154 | ||||
| 136 | // --- Step 9: Create output format context for WebM --- | 155 | // --- Step 9: Create output format context for WebM --- | ||
| 137 | var outFmtCtx unsafe.Pointer | 156 | var outFmtCtx unsafe.Pointer | ||
| 157 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts. | |||
| 138 | if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 { | 158 | if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 { | ||
| 139 | return fmt.Errorf("alloc output context: %d", ret) | 159 | return fmt.Errorf("alloc output context: %d", ret) | ||
| 140 | } | 160 | } | ||
| @@ -146,6 +166,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 146 | if bufSize <= 0 { | 166 | if bufSize <= 0 { | ||
| 147 | return 0 | 167 | return 0 | ||
| 148 | } | 168 | } | ||
| 169 | + | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |||
| 149 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | 170 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | ||
| 150 | n, writeErr := pw.Write(goBuf) | 171 | n, writeErr := pw.Write(goBuf) | ||
| 151 | if writeErr != nil { | 172 | if writeErr != nil { | ||
| @@ -159,6 +180,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 159 | if writeAVIO == nil { | 180 | if writeAVIO == nil { | ||
| 160 | return errors.New("failed to allocate write AVIO context") | 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 | defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO)) | 184 | defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO)) | ||
| 163 | 185 | ||||
| 164 | ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO) | 186 | ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO) | ||
| @@ -184,7 +206,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 184 | audioCodecPar := audioStreamWrap.Codecpar() | 206 | audioCodecPar := audioStreamWrap.Codecpar() | ||
| 185 | 207 | ||||
| 186 | var txErr error | 208 | var txErr error | ||
| 187 | - | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, int(audioIdx)) | 209 | + | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx) |
| 188 | if txErr != nil { | 210 | if txErr != nil { | ||
| 189 | // Audio transcoding failure is non-fatal; we proceed with video only. | 211 | // Audio transcoding failure is non-fatal; we proceed with video only. | ||
| 190 | audioTx = nil | 212 | audioTx = nil | ||
| @@ -210,18 +232,21 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 210 | if pkt == nil { | 232 | if pkt == nil { | ||
| 211 | return errors.New("failed to allocate read packet") | 233 | return errors.New("failed to allocate read packet") | ||
| 212 | } | 234 | } | ||
| 235 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |||
| 213 | defer ffmpeg.PacketFree(unsafe.Pointer(&pkt)) | 236 | defer ffmpeg.PacketFree(unsafe.Pointer(&pkt)) | ||
| 214 | 237 | ||||
| 215 | frame := ffmpeg.FrameAlloc() | 238 | frame := ffmpeg.FrameAlloc() | ||
| 216 | if frame == nil { | 239 | if frame == nil { | ||
| 217 | return errors.New("failed to allocate decode frame") | 240 | return errors.New("failed to allocate decode frame") | ||
| 218 | } | 241 | } | ||
| 242 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |||
| 219 | defer ffmpeg.FrameFree(unsafe.Pointer(&frame)) | 243 | defer ffmpeg.FrameFree(unsafe.Pointer(&frame)) | ||
| 220 | 244 | ||||
| 221 | encPkt := ffmpeg.PacketAlloc() | 245 | encPkt := ffmpeg.PacketAlloc() | ||
| 222 | if encPkt == nil { | 246 | if encPkt == nil { | ||
| 223 | return errors.New("failed to allocate encode packet") | 247 | return errors.New("failed to allocate encode packet") | ||
| 224 | } | 248 | } | ||
| 249 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |||
| 225 | defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt)) | 250 | defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt)) | ||
| 226 | 251 | ||||
| 227 | for { | 252 | for { | ||
| @@ -242,7 +267,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 242 | 267 | ||||
| 243 | switch { | 268 | switch { | ||
| 244 | case streamIdx == videoIdx: | 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 | ffmpeg.PacketUnref(pkt) | 271 | ffmpeg.PacketUnref(pkt) | ||
| 247 | return fmt.Errorf("video transcode: %w", err) | 272 | return fmt.Errorf("video transcode: %w", err) | ||
| 248 | } | 273 | } | ||
| @@ -273,7 +298,10 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 273 | if ret < 0 { | 298 | if ret < 0 { | ||
| 274 | break | 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 | ffmpeg.FrameUnref(frame) | 305 | ffmpeg.FrameUnref(frame) | ||
| 278 | } | 306 | } | ||
| 279 | 307 | ||||
| @@ -305,7 +333,7 @@ func (p *pipeline) doRun(ctx context.Context) error { | |||||
| 305 | return nil | 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 | videoIdx = -1 | 337 | videoIdx = -1 | ||
| 310 | audioIdx = -1 | 338 | audioIdx = -1 | ||
| 311 | bestVideoPixels := int64(-1) | 339 | bestVideoPixels := int64(-1) | ||
| @@ -323,7 +351,7 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, au | |||||
| 323 | } | 351 | } | ||
| 324 | 352 | ||||
| 325 | switch ffmpeg.CodecParCodecType(codecPar) { | 353 | switch ffmpeg.CodecParCodecType(codecPar) { | ||
| 326 | - | case int32(ffmpeg.AVMEDIA_TYPE_VIDEO): | 354 | + | case ffmpeg.AVMEDIA_TYPE_VIDEO: |
| 327 | width := int64(ffmpeg.CodecParWidth(codecPar)) | 355 | width := int64(ffmpeg.CodecParWidth(codecPar)) | ||
| 328 | height := int64(ffmpeg.CodecParHeight(codecPar)) | 356 | height := int64(ffmpeg.CodecParHeight(codecPar)) | ||
| 329 | pixels := width * height | 357 | pixels := width * height | ||
| @@ -331,7 +359,7 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, au | |||||
| 331 | videoIdx = i | 359 | videoIdx = i | ||
| 332 | bestVideoPixels = pixels | 360 | bestVideoPixels = pixels | ||
| 333 | } | 361 | } | ||
| 334 | - | case int32(ffmpeg.AVMEDIA_TYPE_AUDIO): | 362 | + | case ffmpeg.AVMEDIA_TYPE_AUDIO: |
| 335 | if audioIdx == -1 { | 363 | if audioIdx == -1 { | ||
| 336 | audioIdx = i | 364 | audioIdx = i | ||
| 337 | } | 365 | } | ||
| @@ -372,7 +400,7 @@ func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, | |||||
| 372 | } | 400 | } | ||
| 373 | 401 | ||||
| 374 | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | 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 | if err != nil { | 404 | if err != nil { | ||
| 377 | return nil, nil, fmt.Errorf("create request: %w", err) | 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 | req.Header.Set(k, v) | 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 | if err != nil { | 412 | if err != nil { | ||
| 385 | return nil, nil, fmt.Errorf("fetch source: %w", err) | 413 | return nil, nil, fmt.Errorf("fetch source: %w", err) | ||
| 386 | } | 414 | } | ||
| 387 | 415 | ||||
| 388 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | 416 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | ||
| 389 | - | resp.Body.Close() | 417 | + | _ = resp.Body.Close() |
| 390 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | 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 | if bufSize <= 0 { | 423 | if bufSize <= 0 { | ||
| 396 | return -1 | 424 | return -1 | ||
| 397 | } | 425 | } | ||
| 426 | + | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |||
| 398 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | 427 | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | ||
| 399 | // Retry up to 3 times for the edge case where body.Read returns | 428 | // Retry up to 3 times for the edge case where body.Read returns | ||
| 400 | // n=0, err=nil (allowed by the io.Reader contract but unusual). | 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 | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | 445 | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | ||
| 417 | if readAVIO == nil { | 446 | if readAVIO == nil { | ||
| 418 | - | resp.Body.Close() | 447 | + | _ = resp.Body.Close() |
| 419 | return nil, nil, errors.New("failed to allocate read AVIO context") | 448 | return nil, nil, errors.New("failed to allocate read AVIO context") | ||
| 420 | } | 449 | } | ||
| 421 | 450 | ||||
| 422 | inFmtCtx := ffmpeg.FormatAllocContext() | 451 | inFmtCtx := ffmpeg.FormatAllocContext() | ||
| 423 | if inFmtCtx == nil { | 452 | if inFmtCtx == nil { | ||
| 453 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 424 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | 454 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | ||
| 425 | - | resp.Body.Close() | 455 | + | _ = resp.Body.Close() |
| 426 | return nil, nil, errors.New("failed to allocate input format context") | 456 | return nil, nil, errors.New("failed to allocate input format context") | ||
| 427 | } | 457 | } | ||
| 428 | 458 | ||||
| 429 | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | 459 | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | ||
| 430 | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | 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 | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | 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 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | 465 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | ||
| 434 | - | resp.Body.Close() | 466 | + | _ = resp.Body.Close() |
| 435 | ffmpeg.FormatFreeContext(inFmtCtx) | 467 | ffmpeg.FormatFreeContext(inFmtCtx) | ||
| 436 | return nil, nil, fmt.Errorf("open input: %d", ret) | 468 | return nil, nil, fmt.Errorf("open input: %d", ret) | ||
| 437 | } | 469 | } | ||
| 438 | 470 | ||||
| 439 | cleanup := func() { | 471 | cleanup := func() { | ||
| 472 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |||
| 440 | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | 473 | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | ||
| 474 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 441 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | 475 | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | ||
| 442 | - | resp.Body.Close() | 476 | + | _ = resp.Body.Close() |
| 443 | } | 477 | } | ||
| 444 | return inFmtCtx, cleanup, nil | 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 | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | 516 | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | ||
| 482 | ffmpeg.FormatFreeContext(inFmtCtx) | 517 | ffmpeg.FormatFreeContext(inFmtCtx) | ||
| 483 | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | 518 | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | ||
| 484 | } | 519 | } | ||
| 485 | 520 | ||||
| 486 | return inFmtCtx, func() { | 521 | return inFmtCtx, func() { | ||
| 522 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |||
| 487 | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | 523 | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | ||
| 488 | }, nil | 524 | }, nil | ||
| 489 | } | 525 | } | ||
| @@ -494,6 +530,7 @@ func freeInputOptions(opts *unsafe.Pointer) { | |||||
| 494 | } | 530 | } | ||
| 495 | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | 531 | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | ||
| 496 | // itself corrupts memory and can crash inside libavutil during cleanup. | 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 | ffmpeg.DictFree(unsafe.Pointer(opts)) | 534 | ffmpeg.DictFree(unsafe.Pointer(opts)) | ||
| 498 | *opts = nil | 535 | *opts = nil | ||
| 499 | } | 536 | } | ||
| @@ -502,6 +539,7 @@ func setInputOption(opts *unsafe.Pointer, key, value string) error { | |||||
| 502 | if value == "" { | 539 | if value == "" { | ||
| 503 | return nil | 540 | return nil | ||
| 504 | } | 541 | } | ||
| 542 | + | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates. | |||
| 505 | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | 543 | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | ||
| 506 | return fmt.Errorf("set input option %q: %d", key, ret) | 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 | // openVideoDecoder creates and opens a decoder context for the video stream. | 580 | // openVideoDecoder creates and opens a decoder context for the video stream. | ||
| 543 | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | 581 | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | ||
| 544 | // falling back to the generic software decoder. | 582 | // falling back to the generic software decoder. | ||
| 583 | + | // | |||
| 584 | + | //nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup. | |||
| 545 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | 585 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | ||
| 546 | codecID := ffmpeg.CodecParCodecID(codecPar) | 586 | codecID := ffmpeg.CodecParCodecID(codecPar) | ||
| 547 | 587 | ||||
| @@ -582,7 +622,7 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er | |||||
| 582 | 622 | ||||
| 583 | // openVideoEncoder creates a hardware device context and encoder for | 623 | // openVideoEncoder creates a hardware device context and encoder for | ||
| 584 | // the video stream. It uses the first available encoder from hwCaps.Encoders. | 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 | if len(p.hwCaps.Encoders) == 0 { | 626 | if len(p.hwCaps.Encoders) == 0 { | ||
| 587 | return nil, nil, errors.New("no GPU encoders available") | 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 | // Create the HW device context. | 630 | // Create the HW device context. | ||
| 591 | var deviceType int32 | 631 | var deviceType int32 | ||
| 592 | switch p.hwCaps.API { | 632 | switch p.hwCaps.API { | ||
| 593 | - | case "vaapi": | 633 | + | case hwAPINameVAAPI: |
| 594 | deviceType = int32(ffmpeg.HwdeviceTypeVaapi) | 634 | deviceType = int32(ffmpeg.HwdeviceTypeVaapi) | ||
| 595 | case "nvenc": | 635 | case "nvenc": | ||
| 596 | deviceType = int32(ffmpeg.HwdeviceTypeCuda) | 636 | deviceType = int32(ffmpeg.HwdeviceTypeCuda) | ||
| @@ -659,13 +699,13 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||||
| 659 | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | 699 | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | ||
| 660 | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | 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 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi)) | 703 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi)) | ||
| 664 | } else { | 704 | } else { | ||
| 665 | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12)) | 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 | if gopSize <= 0 { | 709 | if gopSize <= 0 { | ||
| 670 | gopSize = 60 | 710 | gopSize = 60 | ||
| 671 | } | 711 | } | ||
| @@ -674,14 +714,16 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||||
| 674 | 714 | ||||
| 675 | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | 715 | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | ||
| 676 | if hwDeviceRef == nil { | 716 | if hwDeviceRef == nil { | ||
| 717 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 677 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 718 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 678 | return nil, errors.New("failed to ref HW device context for encoder") | 719 | return nil, errors.New("failed to ref HW device context for encoder") | ||
| 679 | } | 720 | } | ||
| 680 | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | 721 | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | ||
| 681 | 722 | ||||
| 682 | - | if p.hwCaps.API == "vaapi" { | 723 | + | if p.hwCaps.API == hwAPINameVAAPI { |
| 683 | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | 724 | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | ||
| 684 | if framesRef == nil { | 725 | if framesRef == nil { | ||
| 726 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 685 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 727 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 686 | return nil, errors.New("failed to allocate HW frames context") | 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 | hwFramesData := ffmpeg.BufferRefData(framesRef) | 731 | hwFramesData := ffmpeg.BufferRefData(framesRef) | ||
| 690 | if hwFramesData == nil { | 732 | if hwFramesData == nil { | ||
| 691 | ffmpeg.BufferUnref(&framesRef) | 733 | ffmpeg.BufferUnref(&framesRef) | ||
| 734 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 692 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 735 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 693 | return nil, errors.New("failed to access HW frames context payload") | 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 | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi)) | 740 | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi)) | ||
| 698 | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12)) | 741 | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12)) | ||
| 699 | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | 742 | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | ||
| @@ -701,6 +744,7 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod | |||||
| 701 | 744 | ||||
| 702 | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | 745 | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | ||
| 703 | ffmpeg.BufferUnref(&framesRef) | 746 | ffmpeg.BufferUnref(&framesRef) | ||
| 747 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 704 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 748 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 705 | return nil, fmt.Errorf("init HW frames context: %d", ret) | 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 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | 755 | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | ||
| 756 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 712 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | 757 | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | ||
| 713 | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | 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 | bitrate := int64(float64(pixels) * bitsPerPixel) | 781 | bitrate := int64(float64(pixels) * bitsPerPixel) | ||
| 737 | - | if bitrate < 500_000 { | 782 | + | if bitrate < minVideoBitrate { |
| 738 | - | bitrate = 500_000 // floor at 500 kbps | 783 | + | bitrate = minVideoBitrate // floor at 500 kbps |
| 739 | } | 784 | } | ||
| 740 | codecCtxSetBitRate(encCtx, bitrate) | 785 | codecCtxSetBitRate(encCtx, bitrate) | ||
| 741 | 786 | ||||
| @@ -759,7 +804,7 @@ func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, | |||||
| 759 | 804 | ||||
| 760 | // transcodeVideoPacket decodes a video packet, encodes the resulting | 805 | // transcodeVideoPacket decodes a video packet, encodes the resulting | ||
| 761 | // frames, and writes them to the output. | 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 | ret := ffmpeg.CodecSendPacket(decCtx, pkt) | 808 | ret := ffmpeg.CodecSendPacket(decCtx, pkt) | ||
| 764 | if ret < 0 { | 809 | if ret < 0 { | ||
| 765 | return fmt.Errorf("video decode send packet: %d", ret) | 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 | break | 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 | ffmpeg.FrameUnref(frame) | 821 | ffmpeg.FrameUnref(frame) | ||
| 777 | return err | 822 | return err | ||
| 778 | } | 823 | } | ||
| @@ -784,12 +829,12 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF | |||||
| 784 | 829 | ||||
| 785 | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | 830 | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | ||
| 786 | // writes any resulting packets to the output. | 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 | encFrame := frame | 833 | encFrame := frame | ||
| 789 | var cleanup func() | 834 | var cleanup func() | ||
| 790 | - | if p.hwCaps.API == "vaapi" { | 835 | + | if p.hwCaps.API == hwAPINameVAAPI { |
| 791 | var err error | 836 | var err error | ||
| 792 | - | encFrame, cleanup, err = p.uploadVideoFrameToVAAPI(frame, encCtx) | 837 | + | encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx) |
| 793 | if err != nil { | 838 | if err != nil { | ||
| 794 | return err | 839 | return err | ||
| 795 | } | 840 | } | ||
| @@ -823,7 +868,7 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns | |||||
| 823 | return nil | 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 | if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) { | 872 | if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) { | ||
| 828 | return frame, func() {}, nil | 873 | return frame, func() {}, nil | ||
| 829 | } | 874 | } | ||
| @@ -840,6 +885,7 @@ func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe | |||||
| 840 | 885 | ||||
| 841 | cleanup := func() { | 886 | cleanup := func() { | ||
| 842 | if hwFrame != nil { | 887 | if hwFrame != nil { | ||
| 888 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |||
| 843 | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | 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 | // ContentType returns the MIME type of the transcoded output. | 41 | // ContentType returns the MIME type of the transcoded output. | ||
| 42 | - | func (s *session) ContentType() string { | 42 | + | func (*session) ContentType() string { |
| 43 | return "video/webm" | 43 | return "video/webm" | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| @@ -106,6 +106,6 @@ func (p *sessionPool) closeAll() { | |||||
| 106 | 106 | ||||
| 107 | // Close sessions outside the lock. | 107 | // Close sessions outside the lock. | ||
| 108 | for _, s := range snapshot { | 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 | // Start begins a transcode session. It spawns a goroutine running the | 84 | // Start begins a transcode session. It spawns a goroutine running the | ||
| 85 | // FFmpeg pipeline and returns a TranscodeSession whose Read() streams | 85 | // FFmpeg pipeline and returns a TranscodeSession whose Read() streams | ||
| 86 | // the transcoded WebM output. The session is automatically removed from | 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 | func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) { | 88 | func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) { | ||
| 89 | if !t.Available() { | 89 | if !t.Available() { | ||
| 90 | return nil, errors.New("transcoder: no GPU encoder available") | 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 | // Check capacity and register before spawning the goroutine. | 107 | // Check capacity and register before spawning the goroutine. | ||
| 108 | if err := t.pool.add(s); err != nil { | 108 | if err := t.pool.add(s); err != nil { | ||
| 109 | cancel() | 109 | cancel() | ||
| 110 | - | pr.Close() | 110 | + | _ = pr.Close() |
| 111 | - | pw.Close() | 111 | + | _ = pw.Close() |
| 112 | return nil, fmt.Errorf("transcoder: %w", err) | 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 | defer func() { | 128 | defer func() { | ||
| 129 | if r := recover(); r != nil { | 129 | if r := recover(); r != nil { | ||
| 130 | t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked") | 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 | p.run(sessionCtx) | 134 | p.run(sessionCtx) | ||
M internal/logging/logger_test.go +4 -4
| @@ -27,8 +27,8 @@ func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) { | |||||
| 27 | t.Fatalf("write before close returned %d, want %d", n, len("first\n")) | 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 { | 30 | + | if closeErr := writer.Close(); closeErr != nil { |
| 31 | - | t.Fatalf("close writer: %v", err) | 31 | + | t.Fatalf("close writer: %v", closeErr) |
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | n, err = writer.Write([]byte("second\n")) | 34 | n, err = writer.Write([]byte("second\n")) | ||
| @@ -60,8 +60,8 @@ func TestSessionFileWriter_IgnoresExternallyClosedFile(t *testing.T) { | |||||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | writer := newSessionFileWriter(file) | 62 | writer := newSessionFileWriter(file) | ||
| 63 | - | if err := file.Close(); err != nil { | 63 | + | if closeErr := file.Close(); closeErr != nil { |
| 64 | - | t.Fatalf("close underlying file: %v", err) | 64 | + | t.Fatalf("close underlying file: %v", closeErr) |
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | n, err := writer.Write([]byte("late\n")) | 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 | // Handle internal dumb:// scheme URLs - use app logo | 114 | // Handle internal dumb:// scheme URLs - use app logo | ||
| 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | 115 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | ||
| 116 | - | texture := a.textureFromBytesOnGTK(ctx, a.getLogoBytes()) | 116 | + | callback(a.getOrCreateLogoTexture(ctx)) |
| 117 | - | if texture != nil { | |||
| 118 | - | a.setTexture(a.internalDomain, texture) | |||
| 119 | - | } | |||
| 120 | - | callback(texture) | |||
| 121 | return | 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 | 151 | + | // Try engine FaviconDatabase if available |
| 156 | - | invokeCallback := func(texture *gdk.Texture) { | 152 | + | if a.faviconDB != nil { |
| 157 | - | log.Debug(). | 153 | + | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { |
| 158 | - | Str("domain", domain). | 154 | + | a.handleEngineFaviconDBTexture(ctx, domain, callback, tex) |
| 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 | |||
| 168 | }) | 155 | }) | ||
| 169 | - | glib.IdleAdd(&cb, 0) | 156 | + | return |
| 170 | } | 157 | } | ||
| 171 | 158 | ||||
| 172 | - | // Helper to fetch via service and invoke callback | 159 | + | // No WebKit DB, fetch via service directly |
| 173 | - | fetchViaService := func() { | 160 | + | log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") |
| 174 | - | go func() { | 161 | + | a.fetchViaService(ctx, domain, callback) |
| 175 | - | log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") | 162 | + | } |
| 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 | - | } | |||
| 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 | log.Debug(). | 187 | log.Debug(). | ||
| 188 | + | Err(err). | |||
| 188 | Str("domain", domain). | 189 | Str("domain", domain). | ||
| 189 | Int("bytes", len(data)). | 190 | Int("bytes", len(data)). | ||
| 190 | - | Msg("favicon: fetchViaService fetched data") | 191 | + | Msg("favicon: fetchViaService empty/error result") |
| 191 | - | texture := a.textureFromBytesOnGTK(ctx, data) | 192 | + | a.invokeCallbackOnGTK(ctx, domain, callback, nil) |
| 192 | - | if texture != nil { | 193 | + | return |
| 193 | - | a.setTexture(domain, texture) | 194 | + | } |
| 194 | - | } | |||
| 195 | - | log.Debug(). | |||
| 196 | - | Str("domain", domain). | |||
| 197 | - | Bool("texture_nil", texture == nil). | |||
| 198 | - | Msg("favicon: fetchViaService end") | |||
| 199 | - | invokeCallback(texture) | |||
| 200 | - | }() | |||
| 201 | - | } | |||
| 202 | 195 | ||||
| 203 | - | // Try engine FaviconDatabase if available | 196 | + | log.Debug(). |
| 204 | - | if a.faviconDB != nil { | 197 | + | Str("domain", domain). |
| 205 | - | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | 198 | + | Int("bytes", len(data)). |
| 206 | - | log.Debug(). | 199 | + | Msg("favicon: fetchViaService fetched data") |
| 207 | - | Str("domain", domain). | 200 | + | texture := a.textureFromBytesOnGTK(ctx, data) |
| 208 | - | Bool("texture_nil", tex == nil). | 201 | + | if texture != nil { |
| 209 | - | Msg("favicon: engine db callback") | 202 | + | a.setTexture(domain, texture) |
| 210 | - | if tex == nil { | 203 | + | } |
| 211 | - | log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") | 204 | + | log.Debug(). |
| 212 | - | fetchViaService() | 205 | + | Str("domain", domain). |
| 213 | - | return | 206 | + | Bool("texture_nil", texture == nil). |
| 214 | - | } | 207 | + | Msg("favicon: fetchViaService end") |
| 215 | - | // Convert port.Texture to *gdk.Texture | 208 | + | a.invokeCallbackOnGTK(ctx, domain, callback, texture) |
| 216 | - | gdkTex, ok := tex.(*gdk.Texture) | 209 | + | }() |
| 217 | - | if !ok { | 210 | + | } |
| 218 | - | fetchViaService() | 211 | + | |
| 219 | - | return | 212 | + | func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) { |
| 220 | - | } | 213 | + | log := logging.FromContext(ctx) |
| 221 | - | a.setTexture(domain, gdkTex) | 214 | + | log.Debug(). |
| 222 | - | invokeCallback(gdkTex) | 215 | + | Str("domain", domain). |
| 223 | - | a.saveFaviconToDisk(ctx, domain, gdkTex) | 216 | + | Bool("texture_nil", tex == nil). |
| 224 | - | }) | 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 | return | 221 | return | ||
| 226 | } | 222 | } | ||
| 227 | - | 223 | + | // Convert port.Texture to *gdk.Texture | |
| 228 | - | // No WebKit DB, fetch via service directly | 224 | + | gdkTex, ok := tex.(*gdk.Texture) |
| 229 | - | log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") | 225 | + | if !ok { |
| 230 | - | fetchViaService() | 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 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | 234 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | ||