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