detached 02f4a19

Merge branch 'feat/cef-linux-audio-output' into feat/cef-engine-adapter

commit
02f4a1922e1d448194cda401ff99b112051eaec2
author
brice · · 2026-04-11 07:18 UTC
merge of
dfa0012 + a57cae7 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
39 files · +2622 -474
M go.mod +3 -0
@@ -5,6 +5,7 @@ go 1.26.1
55 require (
66 github.com/bnema/purego-cef v0.8.0
77 github.com/bnema/purego-ffmpeg v0.3.0
8+ github.com/bnema/purego-pipewire v0.0.0-00010101000000-000000000000
89 github.com/bnema/purego-sqlite v0.1.1
910 github.com/bnema/puregotk v0.3.0
1011 github.com/bnema/puregotk-webkit v0.0.16
@@ -30,6 +31,8 @@ replace github.com/bnema/purego-cef => ../purego-cef
3031
3132 replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg
3233
34+replace github.com/bnema/purego-pipewire => ../../../purego-pipewire/.worktrees/dumber-cef-audio
35+
3336 require (
3437 github.com/atotto/clipboard v0.1.4 // indirect
3538 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 (
55 "fmt"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
89 "github.com/bnema/dumber/internal/infrastructure/cef"
910 "github.com/bnema/dumber/internal/infrastructure/config"
1011 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -42,7 +43,8 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
4243 )
4344 case config.EngineTypeCEF:
4445 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)
4648 default:
4749 return nil, fmt.Errorf("unknown engine type: %q", engineType)
4850 }
M internal/bootstrap/handler_deps.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 // BuildHandlerDeps constructs handler dependencies from the config manager.
1313 // It combines keybinding use cases and config save into a single struct.
1414 // 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) {
1616 mgr := config.GetManager()
1717 if mgr == nil {
1818 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 (
99 const dumbSchemeName = "dumb"
1010
1111 func dumbSchemeOptions() int32 {
12- return int32(purecef.SchemeOptionsSchemeOptionStandard |
12+ return purecef.SchemeOptionsSchemeOptionStandard |
1313 purecef.SchemeOptionsSchemeOptionSecure |
1414 purecef.SchemeOptionsSchemeOptionCorsEnabled |
1515 purecef.SchemeOptionsSchemeOptionCspBypassing |
16- purecef.SchemeOptionsSchemeOptionFetchEnabled)
16+ purecef.SchemeOptionsSchemeOptionFetchEnabled
1717 }
1818
1919 func registerDumbScheme(registrar purecef.SchemeRegistrar) {
M internal/infrastructure/cef/app_test.go +2 -2
@@ -25,11 +25,11 @@ func TestRegisterDumbScheme(t *testing.T) {
2525 stub := &schemeRegistrarStub{}
2626 registerDumbScheme(stub)
2727
28- wantOptions := int32(purecef.SchemeOptionsSchemeOptionStandard |
28+ wantOptions := purecef.SchemeOptionsSchemeOptionStandard |
2929 purecef.SchemeOptionsSchemeOptionSecure |
3030 purecef.SchemeOptionsSchemeOptionCorsEnabled |
3131 purecef.SchemeOptionsSchemeOptionCspBypassing |
32- purecef.SchemeOptionsSchemeOptionFetchEnabled)
32+ purecef.SchemeOptionsSchemeOptionFetchEnabled
3333
3434 if stub.calls != 1 {
3535 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(){
252252 function monitorReplacement(video) {
253253 if (!video || video._dumberReplacementDiag) return;
254254 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) {
256259 video.addEventListener(evt, function() {
257260 var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none';
258261 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));
260264 });
261265 });
262266 }
M internal/infrastructure/cef/engine.go +9 -48
@@ -32,7 +32,7 @@ type Engine struct {
3232 activeWebViews sync.Map // map[port.WebViewID]*WebView
3333 activeCount atomic.Int32
3434
35- // shutdownNotify is signalled when the active webview count reaches 0
35+ // shutdownNotify is signaled when the active webview count reaches 0
3636 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
3737 shutdownNotify chan struct{}
3838
@@ -42,14 +42,13 @@ type Engine struct {
4242 childLaunchGPU atomic.Uint64
4343 childLaunchOther atomic.Uint64
4444
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
5352
5453 transcoderState transcoderStartupState
5554 transcoderStateLogged atomic.Bool
@@ -166,6 +165,7 @@ func (e *Engine) closeActiveWebViews() {
166165 }
167166
168167 log := logging.FromContext(e.ctx)
168+ e.shutdownNotify = make(chan struct{}, 1)
169169 webViews := make([]*WebView, 0, e.activeWebViewCount())
170170 e.activeWebViews.Range(func(_, value any) bool {
171171 wv, ok := value.(*WebView)
@@ -183,9 +183,6 @@ func (e *Engine) closeActiveWebViews() {
183183 Int("count", len(webViews)).
184184 Msg("cef: closing active webviews before shutdown")
185185
186- // Set up the shutdown notification channel before destroying webviews.
187- e.shutdownNotify = make(chan struct{}, 1)
188-
189186 for _, wv := range webViews {
190187 wv.Destroy()
191188 }
@@ -262,8 +259,6 @@ func (e *Engine) SetHandlerContext(ctx context.Context) {
262259 e.logTranscoderStartupState()
263260 }
264261
265-const browserCreateStallWarnMS int64 = 1500
266-
267262 func (e *Engine) recordContextInitialized() {
268263 count := e.contextInitializedCount.Add(1)
269264 logging.FromContext(e.ctx).Debug().
@@ -331,40 +326,6 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
331326 Msg("cef: browser created")
332327 }
333328
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-
368329 func (e *Engine) currentContext() context.Context {
369330 if e == nil || e.ctx == nil {
370331 return context.Background()
M internal/infrastructure/cef/engine_init.go +9 -19
@@ -24,7 +24,12 @@ import (
2424 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2525
2626 // 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) {
2833 logger := logging.FromContext(ctx)
2934 cleanStaleSingletonLocks(logger)
3035
@@ -55,7 +60,7 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, transcodingCfg c
5560 }
5661 os.Args = savedArgs
5762
58- return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, logger)
63+ return wireEngine(ctx, eng, cfg, transcodingCfg, windowlessFrameRate, audioFactory, logger)
5964 }
6065
6166 // prepareCEFSettings builds purecef.Settings from the engine config.
@@ -118,7 +123,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
118123 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
119124 func wireEngine(
120125 ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig,
121- windowlessFrameRate int32, logger *zerolog.Logger,
126+ windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
122127 ) (*Engine, error) {
123128 gl, err := newGLLoader()
124129 if err != nil {
@@ -156,6 +161,7 @@ func wireEngine(
156161 windowlessFrameRate: windowlessFrameRate,
157162 enableContextMenuHandler: cfg.EnableContextMenuHandler,
158163 transcoder: mediaTranscoder,
164+ audioOutputFactory: audioFactory,
159165 })
160166 pool := newWebViewPool(factory)
161167
@@ -279,22 +285,6 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 {
279285 return 1
280286 }
281287
282-// detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate.
283-// Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails.
284-func detectMonitorRefreshRate(logger *zerolog.Logger) int32 {
285- mon := getPrimaryMonitor()
286- if mon == nil {
287- return 0
288- }
289- milliHz := mon.GetRefreshRate()
290- if milliHz <= 0 {
291- return 0
292- }
293- hz := int32((milliHz + 500) / 1000) // round to nearest Hz
294- logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate")
295- return hz
296-}
297-
298288 // appendIfMissing appends flag to args if it's not already present.
299289 func appendIfMissing(args []string, flag string) []string {
300290 for _, a := range args {
M internal/infrastructure/cef/engine_init_test.go +10 -0
@@ -5,6 +5,7 @@ import (
55 "path/filepath"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/infrastructure/audio/factory"
89 "github.com/bnema/dumber/internal/infrastructure/config"
910 "github.com/rs/zerolog"
1011 "github.com/stretchr/testify/require"
@@ -64,3 +65,12 @@ func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {
6465 // In dev mode, should resolve under .dev/dumber/cef_user_data
6566 require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath)
6667 }
68+
69+// TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
70+// factory can be created and produces working streams. This tests the runtime
71+// wiring decision to create the factory during CEF engine setup.
72+func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
73+ audioFactory := factory.NewAudioOutputFactory()
74+
75+ require.NotNil(t, audioFactory, "Audio factory should not be nil")
76+}
M internal/infrastructure/cef/factory.go +19 -9
@@ -25,6 +25,7 @@ type WebViewFactory struct {
2525 enableContextMenuHandler bool
2626 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
2727 transcoder port.MediaTranscoder
28+ audioOutputFactory port.AudioOutputFactory
2829 }
2930
3031 type webViewFactoryOptions struct {
@@ -32,6 +33,7 @@ type webViewFactoryOptions struct {
3233 windowlessFrameRate int32
3334 enableContextMenuHandler bool
3435 transcoder port.MediaTranscoder
36+ audioOutputFactory port.AudioOutputFactory
3537 }
3638
3739 // newWebViewFactory returns a factory that will create WebViews using the
@@ -50,6 +52,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
5052 windowlessFrameRate: opts.windowlessFrameRate,
5153 enableContextMenuHandler: opts.enableContextMenuHandler,
5254 transcoder: opts.transcoder,
55+ audioOutputFactory: opts.audioOutputFactory,
5356 }
5457 }
5558
@@ -69,10 +72,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
6972 pipeline := newRenderPipeline(ctx, f.gl, f.scale)
7073
7174 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,
7680 }
7781
7882 var transcodingHandler purecef.ResourceRequestHandler
@@ -135,13 +139,21 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
135139 // Defer browser creation until the GL area has a non-zero size.
136140 // CEF requires GetViewRect to return a non-empty rect, but the GL area
137141 // 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+) {
138151 wv.pendingCreate = &pendingBrowserCreate{
139- windowInfo: &windowInfo,
152+ windowInfo: windowInfo,
140153 client: client,
141- settings: &settings,
154+ settings: settings,
142155 }
143156
144- // When the GL area gets its first non-zero size, create the browser.
145157 pipeline.onFirstResize = func(w, h int32) {
146158 log := logging.FromContext(ctx)
147159 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) {
198210 host.WasResized()
199211 }
200212 }
201-
202- return wv, nil
203213 }
204214
205215 // 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 {
292292
293293 rp.mu.Unlock()
294294
295- gl := rp.gl
296-
297295 if !rp.glReady || sizeChanged {
298296 rp.initGL(uploadWidth, uploadHeight)
299297 }
300298
301299 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)
311301 return true
312302 }
313303
@@ -315,22 +305,12 @@ func (rp *renderPipeline) onGLRender() bool {
315305 uploadStartedAt := time.Now()
316306 uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq)
317307 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)
330309 }
331310 }
332311
333312 // Draw the main view texture.
313+ gl := rp.gl
334314 w, h, _ := rp.viewRectSize()
335315 gl.viewport(0, 0, w, h)
336316 gl.clearColor(0, 0, 0, 1)
@@ -342,13 +322,7 @@ func (rp *renderPipeline) onGLRender() bool {
342322 }
343323
344324 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)
352326 rp.maybeLogDiagnostics()
353327 if rp.ctx != nil {
354328 logging.FromContext(rp.ctx).Trace().
@@ -363,6 +337,33 @@ func (rp *renderPipeline) onGLRender() bool {
363337 return true
364338 }
365339
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+
366367 func (rp *renderPipeline) drawTexture(texture uint32) {
367368 if texture == 0 {
368369 return
@@ -404,63 +405,76 @@ func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool,
404405
405406 bufSize := int64(w) * int64(h) * 4
406407 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+ }
407412
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
410415
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])
412434 gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw)
413435 mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly)
414436 if mapped == nil {
415437 gl.bindBuffer(glPixelUnpackBuffer, 0)
416- return 0, false, false
438+ return 0, false, 0, false
417439 }
418440
419441 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)
423443
424444 rp.mu.Lock()
425445 if !rp.needsUpload {
426446 rp.mu.Unlock()
427447 if !gl.unmapBuffer(glPixelUnpackBuffer) {
428- gl.bindBuffer(glPixelUnpackBuffer, 0)
429- return 0, false, false
448+ return 0, false, 0, false
430449 }
431- gl.bindBuffer(glPixelUnpackBuffer, 0)
432- return 0, false, false
450+ return 0, false, 0, false
433451 }
434452
435453 if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight {
436454 rp.mu.Unlock()
437455 if !gl.unmapBuffer(glPixelUnpackBuffer) {
438- gl.bindBuffer(glPixelUnpackBuffer, 0)
439- return 0, false, false
456+ return 0, false, 0, false
440457 }
441- gl.bindBuffer(glPixelUnpackBuffer, 0)
442458 if rp.ctx != nil {
443459 logging.FromContext(rp.ctx).Trace().
444460 Uint64("render_seq", renderSeq).
445461 Uint64("paint_seq", paintSeq).
446462 Msg("cef: skipping upload while newer surface resize is pending")
447463 }
448- return 0, false, false
464+ return 0, false, 0, false
449465 }
450466
451- fullUpload = rp.forceFullUpload
467+ fullUpload := rp.forceFullUpload
468+ dirtyRectCount, dirtyRects := len(rp.dirtyRects), make([]rect, 0, len(rp.dirtyRects))
452469 if fullUpload {
453470 copied := copy(mappedSlice, rp.staging)
454471 if copied < len(mappedSlice) {
455472 clear(mappedSlice[copied:])
456473 }
457- uploadedBytes = uint64(copied)
458474 } else {
459475 dirtyRects = append(dirtyRects, rp.dirtyRects...)
460- uploadedBytes = copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects)
461476 }
462- rp.needsUpload = false
463- rp.forceFullUpload = false
477+ rp.needsUpload, rp.forceFullUpload = false, false
464478 rp.dirtyRects = rp.dirtyRects[:0]
465479 rp.mu.Unlock()
466480
@@ -472,47 +486,30 @@ func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool,
472486 Bool("full_upload", fullUpload).
473487 Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
474488 }
475- gl.bindBuffer(glPixelUnpackBuffer, 0)
476- return 0, fullUpload, false
489+ return 0, fullUpload, dirtyRectCount, false
477490 }
478491
479- // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.
480492 gl.bindTexture(glTexture2D, rp.texture)
481493 if fullUpload {
482494 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
498497 }
499- gl.bindTexture(glTexture2D, 0)
500-
501- // Swap PBO index for next frame.
502- rp.pboIndex = 1 - rp.pboIndex
503498
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))
514509 }
515- return uploadedBytes, fullUpload, true
510+ gl.pixelStorei(glUnpackRowLength, 0)
511+ gl.bindTexture(glTexture2D, 0)
512+ return uploadedBytes, false, dirtyRectCount, true
516513 }
517514
518515 func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
@@ -1042,15 +1039,16 @@ func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect {
10421039 for changed {
10431040 changed = false
10441041 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
10531046 }
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
10541052 }
10551053 }
10561054 }
M internal/infrastructure/cef/scheme_handler.go +7 -6
@@ -28,11 +28,12 @@ import (
2828
2929 // Scheme path constants matching WebKit's naming.
3030 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
3637 )
3738
3839 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
@@ -192,7 +193,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
192193
193194 ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute)
194195 h.logger.Info().
195- Str("source_url", logging.TruncateURL(sourceURL, 240)).
196+ Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)).
196197 Int("forwarded_header_count", len(headers)).
197198 Msg("scheme handler returning transcoding stream")
198199
M internal/infrastructure/cef/transcoding_handler.go +64 -26
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "errors"
56 "io"
67 "strings"
78 "sync"
@@ -22,6 +23,11 @@ import (
2223
2324 var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
2425
26+const (
27+ maxTranscodingURLLength = 240
28+ httpStatusOK = 200
29+)
30+
2531 // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
2632 // accepts a purecef.ResourceHandler, and the struct implements all methods.
2733
@@ -42,13 +48,16 @@ type transcodingResourceHandler struct {
4248
4349 // Open starts the transcode session asynchronously. CEF will call
4450 // 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 {
4654 // Signal async handling: set handleRequest = 0.
4755 *(*int32)(handleRequest) = 0
4856
4957 log := rh.logger()
58+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
5059 log.Info().
51- Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).
60+ Str("source_url", sourceURL).
5261 Int("forwarded_header_count", len(rh.headers)).
5362 Msg("cef: starting transcoding resource stream")
5463
@@ -57,7 +66,7 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa
5766 if err != nil {
5867 log.Error().
5968 Err(err).
60- Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).
69+ Str("source_url", sourceURL).
6170 Msg("cef: failed to start transcoding resource stream")
6271 // Let CEF know we failed — Cancel will be called.
6372 rh.cancel()
@@ -66,7 +75,7 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa
6675 }
6776 rh.session = session
6877 log.Info().
69- Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).
78+ Str("source_url", sourceURL).
7079 Str("content_type", session.ContentType()).
7180 Msg("cef: transcoding resource stream ready")
7281 callback.Cont()
@@ -76,8 +85,10 @@ func (rh *transcodingResourceHandler) Open(_ purecef.Request, handleRequest unsa
7685 }
7786
7887 // 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)
8192 response.SetStatusText("OK")
8293 response.SetMimeType("video/webm")
8394 response.SetHeaderByName("Accept-Ranges", "none", 1)
@@ -105,7 +116,7 @@ func (rh *transcodingResourceHandler) Read(
105116 rh.totalBytes += int64(n)
106117 if rh.totalBytes == int64(n) {
107118 rh.logger().Info().
108- Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).
119+ Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).
109120 Int("first_chunk_bytes", n).
110121 Msg("cef: transcoding resource stream produced first bytes")
111122 }
@@ -113,14 +124,15 @@ func (rh *transcodingResourceHandler) Read(
113124 return 1
114125 }
115126 if err != nil {
127+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
116128 log := rh.logger().With().
117- Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).
129+ Str("source_url", sourceURL).
118130 Int64("total_bytes", rh.totalBytes).
119131 Logger()
120132 switch {
121- case err == io.EOF && rh.totalBytes == 0:
133+ case errors.Is(err, io.EOF) && rh.totalBytes == 0:
122134 log.Warn().Msg("cef: transcoding resource stream ended before producing data")
123- case err == io.EOF:
135+ case errors.Is(err, io.EOF):
124136 log.Debug().Msg("cef: transcoding resource stream reached EOF")
125137 default:
126138 log.Error().Err(err).Msg("cef: transcoding resource stream read failed")
@@ -132,12 +144,18 @@ func (rh *transcodingResourceHandler) Read(
132144
133145 // Cancel aborts the transcode session and releases resources.
134146 func (rh *transcodingResourceHandler) Cancel() {
147+ sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
135148 rh.logger().Debug().
136- Str("source_url", logging.TruncateURL(rh.sourceURL, 240)).
149+ Str("source_url", sourceURL).
137150 Int64("total_bytes", rh.totalBytes).
138- Msg("cef: transcoding resource stream cancelled")
151+ Msg("cef: transcoding resource stream canceled")
139152 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+ }
141159 }
142160 if rh.cancel != nil {
143161 rh.cancel()
@@ -178,7 +196,9 @@ type requestInfo struct {
178196 // newTranscodingRequestHandler creates a ResourceRequestHandler that detects
179197 // proprietary video containers and HLS/DASH manifest entrypoints, then
180198 // 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 {
182202 return purecef.NewResourceRequestHandler(&transcodingRequestHandler{
183203 transcoder: tc,
184204 ctxf: ctxf,
@@ -189,7 +209,9 @@ func newTranscodingRequestHandler(tc port.MediaTranscoder, ctxf func() context.C
189209 // matches a proprietary video format or a streaming manifest entrypoint, it
190210 // stores the request info and returns 1 to restart the request — at which
191211 // 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 {
193215 if response == nil || request == nil {
194216 return 0
195217 }
@@ -202,7 +224,7 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure
202224 isManifest := !isAlreadyOpen && (transcoder.IsStreamingManifestMIME(mimeType) || transcoder.IsStreamingManifestURL(url))
203225 if isLikelyMediaResponse(url, mimeType) {
204226 log.Debug().
205- Str("url", logging.TruncateURL(url, 240)).
227+ Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
206228 Str("mime_type", mimeType).
207229 Bool("proprietary_match", isProprietary).
208230 Bool("already_open", isAlreadyOpen).
@@ -225,7 +247,7 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure
225247 reason = "streaming-manifest"
226248 }
227249 log.Info().
228- Str("url", logging.TruncateURL(url, 240)).
250+ Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
229251 Str("mime_type", mimeType).
230252 Str("transcode_reason", reason).
231253 Int("forwarded_header_count", len(info.headers)).
@@ -236,7 +258,9 @@ func (h *transcodingRequestHandler) OnResourceResponse(_ purecef.Browser, _ pure
236258
237259 // GetResourceHandler returns a transcoding ResourceHandler for URLs that were
238260 // 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 {
240264 if request == nil {
241265 return nil
242266 }
@@ -275,9 +299,11 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure
275299 }
276300 }
277301 ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute)
302+ shortURL := logging.TruncateURL(url, maxTranscodingURLLength)
303+ shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength)
278304 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).
281307 Bool("eager_match", eager).
282308 Int("forwarded_header_count", len(info.headers)).
283309 Msg("cef: GetResourceHandler returning transcoding resource handler")
@@ -293,22 +319,33 @@ func (h *transcodingRequestHandler) GetResourceHandler(_ purecef.Browser, _ pure
293319
294320 // --- No-op methods required by purecef.ResourceRequestHandler ---
295321
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 {
297325 return nil
298326 }
299327
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 {
301331 return purecef.ReturnValueRvContinue
302332 }
303333
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+) {
305337 }
306338
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 {
308342 return nil
309343 }
310344
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+) {
312349 }
313350
314351 func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ unsafe.Pointer) {
@@ -361,7 +398,8 @@ func (h *transcodingRequestHandler) logf() zerolog.Logger {
361398
362399 func (rh *transcodingResourceHandler) logger() *zerolog.Logger {
363400 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()
365403 return &logger
366404 }
367405 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 {
8080 generation atomic.Uint64
8181 audioPlaying atomic.Bool
8282 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
8392 }
8493
8594 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
@@ -463,6 +472,7 @@ func (wv *WebView) Destroy() {
463472 if !wv.destroyed.CompareAndSwap(false, true) {
464473 return
465474 }
475+ wv.closeAudioStream()
466476 wv.scheduleStopBeginFrameLoop()
467477 wv.mu.RLock()
468478 host := wv.host
@@ -705,3 +715,21 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
705715 wv.pendingCreate = nil
706716 return pc
707717 }
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 @@
11 package cef
22
33 import (
4+ "context"
45 "strings"
56
67 purecef "github.com/bnema/purego-cef/cef"
@@ -327,7 +328,8 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
327328 }
328329
329330 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)) {
331333 log := logging.FromContext(h.wv.ctx).With().
332334 Str("component", "cef-console").
333335 Str("source", source).
@@ -460,8 +462,6 @@ func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.E
460462 // OnBeforePopup intercepts popup requests (target="_blank", window.open).
461463 // CEF OSR cannot create popup windows, so we fire the OnCreate callback
462464 // to let the coordinator open the link in a new stacked pane.
463-//
464-//nolint:gocritic // signature imposed by purecef.LifeSpanHandler interface
465465 func (h *handlerSet) OnBeforePopup(
466466 _ purecef.Browser, _ purecef.Frame, _ int32, targetURL, targetFrameName string,
467467 _ purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
@@ -493,7 +493,6 @@ func (h *handlerSet) OnBeforePopup(
493493
494494 func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, _ int32) {}
495495
496-//nolint:gocritic // signature imposed by purecef.LifeSpanHandler interface
497496 func (h *handlerSet) OnBeforeDevToolsPopup(
498497 _ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.Client,
499498 _ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
@@ -600,7 +599,7 @@ func (h *handlerSet) GetResourceRequestHandler(
600599 *disableDefaultHandling = 1
601600 if h.wv != nil && h.wv.ctx != nil {
602601 logging.FromContext(h.wv.ctx).Info().
603- Str("url", logging.TruncateURL(request.GetURL(), 240)).
602+ Str("url", logging.TruncateURL(request.GetURL(), maxTranscodingURLLength)).
604603 Msg("cef: disabled default handling for eager transcode candidate")
605604 }
606605 }
@@ -688,18 +687,157 @@ func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParam
688687 return 1 // proceed with defaults
689688 }
690689
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
692739 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()
693753 }
694754
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()
696805
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.
697819 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+ }
698826 h.wv.setAudioPlaying(false)
827+ h.wv.closeAudioStream()
699828 }
700829
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+ }
702839 h.wv.setAudioPlaying(false)
840+ h.wv.closeAudioStream()
703841 }
704842
705843 // ===========================================================================
M internal/infrastructure/config/defaults.go +1 -0
@@ -161,6 +161,7 @@ func DefaultConfig() *Config {
161161 CEF: CEFEngineConfig{
162162 WindowlessFrameRate: defaultCEFWindowlessFrameRate,
163163 LogFile: "",
164+ EnableAudioHandler: true,
164165 },
165166 WebKit: WebKitEngineConfig{
166167 ITPEnabled: true,
M internal/infrastructure/config/defaults_test.go +1 -1
@@ -22,7 +22,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
2222 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
2323 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
2424 assert.Empty(t, cfg.Engine.CEF.LogFile)
25- assert.False(t, cfg.Engine.CEF.EnableAudioHandler)
25+ assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
2626 assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler)
2727 assert.False(t, cfg.Engine.CEF.TraceHandlers)
2828 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
M internal/infrastructure/config/engine.go +12 -12
@@ -7,6 +7,17 @@ const (
77 EngineTypeCEF = "cef"
88 )
99
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+
1021 // ResolveEngineType returns the effective engine type from config + env override.
1122 // It defaults to "webkit" when no type is configured, and allows DUMBER_ENGINE
1223 // to override the configured value for smoke testing.
@@ -21,17 +32,6 @@ func (e *EngineConfig) ResolveEngineType() string {
2132 return engineType
2233 }
2334
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-
3535 // WebKitEngineConfig holds WebKit-specific engine options.
3636 type WebKitEngineConfig struct {
3737 // Skia rendering threads
@@ -96,7 +96,7 @@ type CEFEngineConfig struct {
9696 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
9797 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
9898 // 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
100100 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
101101 TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"`
102102 }
M internal/infrastructure/config/validation.go +5 -2
@@ -10,6 +10,8 @@ import (
1010 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
1111 )
1212
13+const cefLogSeverityDisabled = 99
14+
1315 // validateConfig performs comprehensive validation of configuration values
1416 func validateConfig(config *Config) error {
1517 var validationErrors []string
@@ -329,10 +331,11 @@ func validateCEF(config *Config) []string {
329331 var validationErrors []string
330332
331333 switch config.Engine.CEF.LogSeverity {
332- case 0, 1, 2, 3, 4, 99:
334+ case 0, 1, 2, 3, 4, cefLogSeverityDisabled:
333335 default:
334336 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,
336339 config.Engine.CEF.LogSeverity,
337340 ))
338341 }
M internal/infrastructure/config/writer_test.go +10 -6
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "bytes"
45 "os"
56 "path/filepath"
67 "strings"
@@ -48,17 +49,20 @@ func TestWriteConfigOrdered(t *testing.T) {
4849 }
4950 // Match "enabled = true" specifically within the [transcoding] section to
5051 // 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
5559 if nextSection >= 0 {
5660 transcodingBlock = afterTranscoding[:nextSection+1]
5761 } else {
5862 transcodingBlock = afterTranscoding
5963 }
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))
6266 }
6367
6468 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) {
5151 Return(nil, nil) // nil filter triggers fallback to download
5252
5353 // Fallback: download will be triggered since Load returned nil filter
54+ mockDownloader.EXPECT().
55+ GetCachedFilterPaths().
56+ Return(nil)
57+
5458 mockDownloader.EXPECT().
5559 DownloadFilters(mock.Anything, mock.Anything).
5660 Return([]string{jsonFile}, nil)
@@ -140,6 +144,10 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
140144 Return([]string{}, nil)
141145
142146 // Download should be triggered
147+ mockDownloader.EXPECT().
148+ GetCachedFilterPaths().
149+ Return(nil)
150+
143151 mockDownloader.EXPECT().
144152 DownloadFilters(mock.Anything, mock.Anything).
145153 Return([]string{jsonFile}, nil)
@@ -455,6 +463,9 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
455463 mockStore.EXPECT().
456464 Load(mock.Anything, "ublock-combined-0").
457465 Return(nil, nil) // nil triggers download fallback
466+ mockDownloader.EXPECT().
467+ GetCachedFilterPaths().
468+ Return(nil)
458469 mockDownloader.EXPECT().
459470 DownloadFilters(mock.Anything, mock.Anything).
460471 Return([]string{jsonFile}, nil)
@@ -519,6 +530,9 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
519530 mockStore.EXPECT().
520531 FetchIdentifiers(mock.Anything).
521532 Return([]string{}, nil)
533+ mockDownloader.EXPECT().
534+ GetCachedFilterPaths().
535+ Return(nil)
522536
523537 mockDownloader.EXPECT().
524538 DownloadFilters(mock.Anything, mock.Anything).
M internal/infrastructure/transcoder/audio.go +88 -73
@@ -20,6 +20,35 @@ const opusBitrate = 128000
2020 // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration.
2121 const opusFrameSize = 960
2222
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+
2352 // audioTranscoder decodes an input audio stream (typically AAC) and
2453 // re-encodes it to Opus, performing sample rate conversion if needed.
2554 type audioTranscoder struct {
@@ -38,6 +67,11 @@ type audioTranscoder struct {
3867 // outFmtCtx is the AVFormatContext* for the output container (used to
3968 // add the output stream). inStreamIdx is the input stream index.
4069 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) {
4175 // --- Decoder setup ---
4276 codecID := ffmpeg.CodecParCodecID(inCodecPar)
4377 dec := ffmpeg.CodecFindDecoder(codecID)
@@ -51,25 +85,25 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
5185 }
5286
5387 if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 {
54- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
88+ freeCodecContext(&decCtx)
5589 return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret)
5690 }
5791
5892 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
59- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
93+ freeCodecContext(&decCtx)
6094 return nil, fmt.Errorf("failed to open audio decoder: %d", ret)
6195 }
6296
6397 // --- Encoder setup (libopus) ---
6498 enc := ffmpeg.CodecFindEncoderByName("libopus")
6599 if enc == nil {
66- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
100+ freeCodecContext(&decCtx)
67101 return nil, errors.New("libopus encoder not found")
68102 }
69103
70104 encCtx := ffmpeg.CodecAllocContext3(enc)
71105 if encCtx == nil {
72- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
106+ freeCodecContext(&decCtx)
73107 return nil, errors.New("failed to allocate audio encoder context")
74108 }
75109
@@ -88,16 +122,16 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
88122 // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus.
89123
90124 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)
93127 return nil, fmt.Errorf("failed to open audio encoder: %d", ret)
94128 }
95129
96130 // --- Add output stream ---
97131 outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil)
98132 if outStreamPtr == nil {
99- ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
100- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
133+ freeCodecContext(&encCtx)
134+ freeCodecContext(&decCtx)
101135 return nil, errors.New("failed to create output audio stream")
102136 }
103137
@@ -106,8 +140,8 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
106140
107141 // Copy encoder parameters to the output stream's codecpar.
108142 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)
111145 return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret)
112146 }
113147
@@ -122,8 +156,8 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
122156 if needResample {
123157 swr = ffmpeg.SwrAlloc()
124158 if swr == nil {
125- ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
126- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
159+ freeCodecContext(&encCtx)
160+ freeCodecContext(&decCtx)
127161 return nil, errors.New("failed to allocate resampler")
128162 }
129163
@@ -142,18 +176,18 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
142176 swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout)
143177
144178 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)
148182 return nil, fmt.Errorf("failed to init resampler: %d", ret)
149183 }
150184
151185 // Allocate a reusable output frame for resampled data.
152186 resampledFrame = ffmpeg.FrameAlloc()
153187 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)
157191 return nil, errors.New("failed to allocate resampled frame")
158192 }
159193
@@ -167,27 +201,27 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
167201 decFrame := ffmpeg.FrameAlloc()
168202 if decFrame == nil {
169203 if resampledFrame != nil {
170- ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame))
204+ freeFrame(&resampledFrame)
171205 }
172206 if swr != nil {
173- ffmpeg.SwrFree(unsafe.Pointer(&swr))
207+ freeSwrContext(&swr)
174208 }
175- ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
176- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
209+ freeCodecContext(&encCtx)
210+ freeCodecContext(&decCtx)
177211 return nil, errors.New("failed to allocate audio decode frame")
178212 }
179213
180214 encPkt := ffmpeg.PacketAlloc()
181215 if encPkt == nil {
182- ffmpeg.FrameFree(unsafe.Pointer(&decFrame))
216+ freeFrame(&decFrame)
183217 if resampledFrame != nil {
184- ffmpeg.FrameFree(unsafe.Pointer(&resampledFrame))
218+ freeFrame(&resampledFrame)
185219 }
186220 if swr != nil {
187- ffmpeg.SwrFree(unsafe.Pointer(&swr))
221+ freeSwrContext(&swr)
188222 }
189- ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
190- ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
223+ freeCodecContext(&encCtx)
224+ freeCodecContext(&decCtx)
191225 return nil, errors.New("failed to allocate audio encode packet")
192226 }
193227
@@ -207,17 +241,17 @@ func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (
207241 // to Opus, and writes the resulting packets to outFmtCtx.
208242 func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
209243 // 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)
213247 }
214248
215249 frame := a.decFrame
216250 encPkt := a.encPkt
217251
218252 for {
219- ret = ffmpeg.CodecReceiveFrame(a.decoder, frame)
220- if ret < 0 {
253+ recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame)
254+ if recvRet < 0 {
221255 // EAGAIN or EOF — no more frames from this packet.
222256 break
223257 }
@@ -227,27 +261,16 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
227261 if a.resampler != nil {
228262 encFrame = a.frame
229263
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)
237264 inSamples := frameNbSamples(frame)
238265
239266 // Allocate buffer for output frame if needed.
240267 frameSetNbSamples(a.frame, opusFrameSize)
241- if ret := ffmpeg.FrameGetBuffer(a.frame, 0); ret < 0 {
268+ if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 {
242269 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)
244271 }
245272
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)
251274 if converted < 0 {
252275 ffmpeg.FrameUnref(frame)
253276 return fmt.Errorf("audio resample failed: %d", converted)
@@ -264,15 +287,15 @@ func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error {
264287 }
265288
266289 // Send frame to encoder.
267- if ret := ffmpeg.CodecSendFrame(a.encoder, encFrame); ret < 0 {
290+ if sendFrameRet := ffmpeg.CodecSendFrame(a.encoder, encFrame); sendFrameRet < 0 {
268291 ffmpeg.FrameUnref(frame)
269- return fmt.Errorf("audio encode send frame: %d", ret)
292+ return fmt.Errorf("audio encode send frame: %d", sendFrameRet)
270293 }
271294
272295 // Receive encoded packets.
273296 for {
274- ret = ffmpeg.CodecReceivePacket(a.encoder, encPkt)
275- if ret < 0 {
297+ recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
298+ if recvPktRet < 0 {
276299 break
277300 }
278301
@@ -309,32 +332,24 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
309332
310333 // Drain decoded frames.
311334 for {
312- ret := ffmpeg.CodecReceiveFrame(a.decoder, frame)
313- if ret < 0 {
335+ recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame)
336+ if recvRet < 0 {
314337 break
315338 }
316339
317340 encFrame := frame
318341 if a.resampler != nil {
319342 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)
324343 inSamples := frameNbSamples(frame)
325344
326345 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")
329348 ffmpeg.FrameUnref(frame)
330349 continue
331350 }
332351
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)
338353 if converted < 0 {
339354 logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush")
340355 ffmpeg.FrameUnref(frame)
@@ -346,8 +361,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
346361 ffmpeg.CodecSendFrame(a.encoder, encFrame)
347362
348363 for {
349- ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
350- if ret < 0 {
364+ recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
365+ if recvPktRet < 0 {
351366 break
352367 }
353368 pktSetStreamIndex(encPkt, int32(a.outStream))
@@ -364,8 +379,8 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
364379 // Flush encoder by sending NULL frame.
365380 ffmpeg.CodecSendFrame(a.encoder, nil)
366381 for {
367- ret := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
368- if ret < 0 {
382+ flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt)
383+ if flushPktRet < 0 {
369384 break
370385 }
371386 pktSetStreamIndex(encPkt, int32(a.outStream))
@@ -380,21 +395,21 @@ func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger)
380395 // close frees all FFmpeg resources owned by the audio transcoder.
381396 func (a *audioTranscoder) close() {
382397 if a.encPkt != nil {
383- ffmpeg.PacketFree(unsafe.Pointer(&a.encPkt))
398+ freePacket(&a.encPkt)
384399 }
385400 if a.decFrame != nil {
386- ffmpeg.FrameFree(unsafe.Pointer(&a.decFrame))
401+ freeFrame(&a.decFrame)
387402 }
388403 if a.frame != nil {
389- ffmpeg.FrameFree(unsafe.Pointer(&a.frame))
404+ freeFrame(&a.frame)
390405 }
391406 if a.resampler != nil {
392- ffmpeg.SwrFree(unsafe.Pointer(&a.resampler))
407+ freeSwrContext(&a.resampler)
393408 }
394409 if a.encoder != nil {
395- ffmpeg.CodecFreeContext(unsafe.Pointer(&a.encoder))
410+ freeCodecContext(&a.encoder)
396411 }
397412 if a.decoder != nil {
398- ffmpeg.CodecFreeContext(unsafe.Pointer(&a.decoder))
413+ freeCodecContext(&a.decoder)
399414 }
400415 }
M internal/infrastructure/transcoder/detect.go +4 -1
@@ -113,7 +113,10 @@ func IsEagerTranscodeURL(rawURL string) bool {
113113 // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs
114114 // that should be intercepted and resolved to an underlying source URL.
115115 func IsSyntheticTranscodeURL(rawURL string) bool {
116- _, _, _, ok := ParseSyntheticTranscodeURL(rawURL)
116+ sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL)
117+ _ = sourceURL
118+ _ = referer
119+ _ = origin
117120 return ok
118121 }
119122
M internal/infrastructure/transcoder/ffmpeg_offsets.go +11 -48
@@ -20,8 +20,6 @@ import "unsafe"
2020 // ---------------------------------------------------------------------------
2121
2222 const (
23- offsetPktPts = 8
24- offsetPktDts = 16
2523 offsetPktStreamIndex = 36
2624 )
2725
@@ -91,7 +89,7 @@ func frameSetFormat(f unsafe.Pointer, v int32) {
9189 *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v
9290 }
9391
94-func frameSetHWFramesCtx(f unsafe.Pointer, v unsafe.Pointer) {
92+func frameSetHWFramesCtx(f, v unsafe.Pointer) {
9593 *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v
9694 }
9795
@@ -103,10 +101,6 @@ func frameSetPts(f unsafe.Pointer, v int64) {
103101 *(*int64)(unsafe.Add(f, offsetFramePts)) = v
104102 }
105103
106-func frameSampleRate(f unsafe.Pointer) int32 {
107- return *(*int32)(unsafe.Add(f, offsetFrameSampleRate))
108-}
109-
110104 func frameSetSampleRate(f unsafe.Pointer, v int32) {
111105 *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v
112106 }
@@ -116,24 +110,11 @@ func frameSetSampleRate(f unsafe.Pointer, v int32) {
116110 // ---------------------------------------------------------------------------
117111
118112 const (
119- offsetCodecCtxFlags = 64
120113 offsetCodecCtxGopSize = 332
121114 offsetCodecCtxBitRate = 56
122115 offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes)
123116 )
124117
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-
137118 func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) {
138119 *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v
139120 }
@@ -144,13 +125,15 @@ func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) {
144125
145126 // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst.
146127 // 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.
147130 func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
148131 dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout)
149132 srcLayout := unsafe.Add(src, offsetCodecCtxChLayout)
150133 // AVChannelLayout is 24 bytes in FFmpeg 8.x.
151134 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),
154137 )
155138 }
156139
@@ -161,41 +144,21 @@ func codecCtxCopyChLayout(dst, src unsafe.Pointer) {
161144 // ---------------------------------------------------------------------------
162145
163146 const (
147+ channelLayoutSize = 24
148+
164149 offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes)
165150 offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes)
166151 )
167152
168153 // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from
169154 // 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.
170157 func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) {
171158 dst := unsafe.Add(swr, swrOffset)
172159 src := unsafe.Add(codecCtx, offsetCodecCtxChLayout)
173160 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),
200163 )
201164 }
M internal/infrastructure/transcoder/hwprobe.go +5 -3
@@ -16,6 +16,8 @@ var usableEncoders = map[string]bool{
1616 "av1_nvenc": true,
1717 }
1818
19+const hwAPINameVAAPI = "vaapi"
20+
1921 // isUsableEncoder returns true only for hardware encoders whose output
2022 // codec is open and decodable by CEF (AV1, VP9).
2123 func isUsableEncoder(name string) bool {
@@ -31,7 +33,7 @@ type hwProfile struct {
3133 }
3234
3335 var vaAPIProfile = hwProfile{
34- api: "vaapi",
36+ api: hwAPINameVAAPI,
3537 deviceType: int32(ffmpeg.HwdeviceTypeVaapi),
3638 encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"},
3739 decoders: []string{"h264_vaapi", "hevc_vaapi"},
@@ -59,11 +61,11 @@ func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities {
5961
6062 var profiles []hwProfile
6163 switch hwaccelPref {
62- case "vaapi":
64+ case hwAPINameVAAPI:
6365 profiles = []hwProfile{vaAPIProfile}
6466 case "nvenc":
6567 profiles = []hwProfile{nvencProfile}
66- default: // "auto" or unrecognised
68+ default: // "auto" or unrecognized
6769 profiles = []hwProfile{vaAPIProfile, nvencProfile}
6870 }
6971
M internal/infrastructure/transcoder/pipeline.go +78 -32
@@ -24,6 +24,11 @@ import (
2424 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
2525 const avioBufSize = 32 * 1024
2626
27+const (
28+ vaapiInitialPoolSize = 20
29+ minVideoBitrate = 500_000
30+)
31+
2732 // pipeline orchestrates a single GPU transcode session. It reads from
2833 // an HTTP source, decodes with a hardware decoder (or software fallback),
2934 // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to
@@ -36,7 +41,6 @@ type pipeline struct {
3641 headers map[string]string
3742 quality string
3843 pw *io.PipeWriter
39- cancel context.CancelFunc
4044 logger zerolog.Logger
4145
4246 // Prevent GC of callback closures and uintptrs.
@@ -55,7 +59,15 @@ var httpClient = &http.Client{
5559 },
5660 }
5761
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 {
5971 return &pipeline{
6072 sessionID: sessionID,
6173 hwCaps: hwCaps,
@@ -77,23 +89,28 @@ func (p *pipeline) run(ctx context.Context) {
7789 Str("source_url", p.sourceURL).
7890 Logger()
7991 if errors.Is(err, context.Canceled) {
80- log.Debug().Err(err).Msg("transcode pipeline cancelled")
92+ log.Debug().Err(err).Msg("transcode pipeline canceled")
8193 } else {
8294 log.Error().Err(err).Msg("transcode pipeline failed")
8395 }
84- p.pw.CloseWithError(err)
96+ _ = p.pw.CloseWithError(err)
8597 } else {
8698 p.logger.Debug().
8799 Str("session_id", p.sessionID).
88100 Str("source_url", p.sourceURL).
89101 Msg("transcode pipeline completed")
90- p.pw.Close()
102+ _ = p.pw.Close()
91103 }
92104 }
93105
94106 // doRun contains the actual pipeline logic. Errors are returned, and
95107 // the caller (run) closes the pipe writer accordingly.
96108 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 {
97114 // --- Step 1-3: Open the input using the appropriate strategy ---
98115 inFmtCtx, closeInput, err := p.openInputFormatContext(ctx)
99116 if err != nil {
@@ -121,6 +138,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
121138 if err != nil {
122139 return fmt.Errorf("open video decoder: %w", err)
123140 }
141+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
124142 defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx))
125143
126144 // --- Step 7-8: Open video encoder with HW accel ---
@@ -131,10 +149,12 @@ func (p *pipeline) doRun(ctx context.Context) error {
131149 if hwDeviceCtx != nil {
132150 defer ffmpeg.BufferUnref(&hwDeviceCtx)
133151 }
152+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
134153 defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx))
135154
136155 // --- Step 9: Create output format context for WebM ---
137156 var outFmtCtx unsafe.Pointer
157+ //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts.
138158 if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 {
139159 return fmt.Errorf("alloc output context: %d", ret)
140160 }
@@ -146,6 +166,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
146166 if bufSize <= 0 {
147167 return 0
148168 }
169+ //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here.
149170 goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
150171 n, writeErr := pw.Write(goBuf)
151172 if writeErr != nil {
@@ -159,6 +180,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
159180 if writeAVIO == nil {
160181 return errors.New("failed to allocate write AVIO context")
161182 }
183+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
162184 defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO))
163185
164186 ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO)
@@ -184,7 +206,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
184206 audioCodecPar := audioStreamWrap.Codecpar()
185207
186208 var txErr error
187- audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, int(audioIdx))
209+ audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx)
188210 if txErr != nil {
189211 // Audio transcoding failure is non-fatal; we proceed with video only.
190212 audioTx = nil
@@ -210,18 +232,21 @@ func (p *pipeline) doRun(ctx context.Context) error {
210232 if pkt == nil {
211233 return errors.New("failed to allocate read packet")
212234 }
235+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations.
213236 defer ffmpeg.PacketFree(unsafe.Pointer(&pkt))
214237
215238 frame := ffmpeg.FrameAlloc()
216239 if frame == nil {
217240 return errors.New("failed to allocate decode frame")
218241 }
242+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations.
219243 defer ffmpeg.FrameFree(unsafe.Pointer(&frame))
220244
221245 encPkt := ffmpeg.PacketAlloc()
222246 if encPkt == nil {
223247 return errors.New("failed to allocate encode packet")
224248 }
249+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations.
225250 defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt))
226251
227252 for {
@@ -242,7 +267,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
242267
243268 switch {
244269 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 {
246271 ffmpeg.PacketUnref(pkt)
247272 return fmt.Errorf("video transcode: %w", err)
248273 }
@@ -273,7 +298,10 @@ func (p *pipeline) doRun(ctx context.Context) error {
273298 if ret < 0 {
274299 break
275300 }
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+ }
277305 ffmpeg.FrameUnref(frame)
278306 }
279307
@@ -305,7 +333,7 @@ func (p *pipeline) doRun(ctx context.Context) error {
305333 return nil
306334 }
307335
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) {
309337 videoIdx = -1
310338 audioIdx = -1
311339 bestVideoPixels := int64(-1)
@@ -323,7 +351,7 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, au
323351 }
324352
325353 switch ffmpeg.CodecParCodecType(codecPar) {
326- case int32(ffmpeg.AVMEDIA_TYPE_VIDEO):
354+ case ffmpeg.AVMEDIA_TYPE_VIDEO:
327355 width := int64(ffmpeg.CodecParWidth(codecPar))
328356 height := int64(ffmpeg.CodecParHeight(codecPar))
329357 pixels := width * height
@@ -331,7 +359,7 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx int, au
331359 videoIdx = i
332360 bestVideoPixels = pixels
333361 }
334- case int32(ffmpeg.AVMEDIA_TYPE_AUDIO):
362+ case ffmpeg.AVMEDIA_TYPE_AUDIO:
335363 if audioIdx == -1 {
336364 audioIdx = i
337365 }
@@ -372,7 +400,7 @@ func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer,
372400 }
373401
374402 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)
376404 if err != nil {
377405 return nil, nil, fmt.Errorf("create request: %w", err)
378406 }
@@ -380,13 +408,13 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
380408 req.Header.Set(k, v)
381409 }
382410
383- resp, err := httpClient.Do(req)
411+ resp, err := httpClient.Do(req) //nolint:bodyclose // closed by returned cleanup after FFmpeg finishes reading.
384412 if err != nil {
385413 return nil, nil, fmt.Errorf("fetch source: %w", err)
386414 }
387415
388416 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
389- resp.Body.Close()
417+ _ = resp.Body.Close()
390418 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)
391419 }
392420
@@ -395,6 +423,7 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
395423 if bufSize <= 0 {
396424 return -1
397425 }
426+ //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here.
398427 goBuf := unsafe.Slice((*byte)(buf), int(bufSize))
399428 // Retry up to 3 times for the edge case where body.Read returns
400429 // 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
415444
416445 readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0)
417446 if readAVIO == nil {
418- resp.Body.Close()
447+ _ = resp.Body.Close()
419448 return nil, nil, errors.New("failed to allocate read AVIO context")
420449 }
421450
422451 inFmtCtx := ffmpeg.FormatAllocContext()
423452 if inFmtCtx == nil {
453+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
424454 ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
425- resp.Body.Close()
455+ _ = resp.Body.Close()
426456 return nil, nil, errors.New("failed to allocate input format context")
427457 }
428458
429459 ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO)
430460 ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO)
431461
462+ //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts.
432463 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.
433465 ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
434- resp.Body.Close()
466+ _ = resp.Body.Close()
435467 ffmpeg.FormatFreeContext(inFmtCtx)
436468 return nil, nil, fmt.Errorf("open input: %d", ret)
437469 }
438470
439471 cleanup := func() {
472+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
440473 ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
474+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts.
441475 ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO))
442- resp.Body.Close()
476+ _ = resp.Body.Close()
443477 }
444478 return inFmtCtx, cleanup, nil
445479 }
@@ -478,12 +512,14 @@ func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) {
478512 }
479513 }
480514
515+ //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries.
481516 if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 {
482517 ffmpeg.FormatFreeContext(inFmtCtx)
483518 return nil, nil, fmt.Errorf("open manifest input: %d", ret)
484519 }
485520
486521 return inFmtCtx, func() {
522+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
487523 ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
488524 }, nil
489525 }
@@ -494,6 +530,7 @@ func freeInputOptions(opts *unsafe.Pointer) {
494530 }
495531 // av_dict_free expects an AVDictionary**. Passing the dictionary pointer
496532 // itself corrupts memory and can crash inside libavutil during cleanup.
533+ //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup.
497534 ffmpeg.DictFree(unsafe.Pointer(opts))
498535 *opts = nil
499536 }
@@ -502,6 +539,7 @@ func setInputOption(opts *unsafe.Pointer, key, value string) error {
502539 if value == "" {
503540 return nil
504541 }
542+ //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates.
505543 if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 {
506544 return fmt.Errorf("set input option %q: %d", key, ret)
507545 }
@@ -542,6 +580,8 @@ func formatHTTPHeaderOptions(headers map[string]string) string {
542580 // openVideoDecoder creates and opens a decoder context for the video stream.
543581 // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
544582 // falling back to the generic software decoder.
583+//
584+//nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup.
545585 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
546586 codecID := ffmpeg.CodecParCodecID(codecPar)
547587
@@ -582,7 +622,7 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er
582622
583623 // openVideoEncoder creates a hardware device context and encoder for
584624 // 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) {
586626 if len(p.hwCaps.Encoders) == 0 {
587627 return nil, nil, errors.New("no GPU encoders available")
588628 }
@@ -590,7 +630,7 @@ func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx unsafe.P
590630 // Create the HW device context.
591631 var deviceType int32
592632 switch p.hwCaps.API {
593- case "vaapi":
633+ case hwAPINameVAAPI:
594634 deviceType = int32(ffmpeg.HwdeviceTypeVaapi)
595635 case "nvenc":
596636 deviceType = int32(ffmpeg.HwdeviceTypeCuda)
@@ -659,13 +699,13 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod
659699 ffmpeg.CodecCtxSetFramerate(encCtx, framerate)
660700 ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num})
661701
662- if p.hwCaps.API == "vaapi" {
702+ if p.hwCaps.API == hwAPINameVAAPI {
663703 ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi))
664704 } else {
665705 ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12))
666706 }
667707
668- gopSize := int32(framerate.Num / framerate.Den * 2)
708+ gopSize := framerate.Num / framerate.Den * 2
669709 if gopSize <= 0 {
670710 gopSize = 60
671711 }
@@ -674,14 +714,16 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod
674714
675715 hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx)
676716 if hwDeviceRef == nil {
717+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
677718 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
678719 return nil, errors.New("failed to ref HW device context for encoder")
679720 }
680721 ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef)
681722
682- if p.hwCaps.API == "vaapi" {
723+ if p.hwCaps.API == hwAPINameVAAPI {
683724 framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx)
684725 if framesRef == nil {
726+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
685727 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
686728 return nil, errors.New("failed to allocate HW frames context")
687729 }
@@ -689,11 +731,12 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod
689731 hwFramesData := ffmpeg.BufferRefData(framesRef)
690732 if hwFramesData == nil {
691733 ffmpeg.BufferUnref(&framesRef)
734+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
692735 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
693736 return nil, errors.New("failed to access HW frames context payload")
694737 }
695738
696- ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, 20)
739+ ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, vaapiInitialPoolSize)
697740 ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi))
698741 ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12))
699742 ffmpeg.HWFramesCtxSetWidth(hwFramesData, width)
@@ -701,6 +744,7 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod
701744
702745 if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 {
703746 ffmpeg.BufferUnref(&framesRef)
747+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
704748 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
705749 return nil, fmt.Errorf("init HW frames context: %d", ret)
706750 }
@@ -709,6 +753,7 @@ func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encod
709753 }
710754
711755 if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 {
756+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
712757 ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx))
713758 return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret)
714759 }
@@ -734,8 +779,8 @@ func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string,
734779 }
735780
736781 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
739784 }
740785 codecCtxSetBitRate(encCtx, bitrate)
741786
@@ -759,7 +804,7 @@ func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string,
759804
760805 // transcodeVideoPacket decodes a video packet, encodes the resulting
761806 // 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 {
763808 ret := ffmpeg.CodecSendPacket(decCtx, pkt)
764809 if ret < 0 {
765810 return fmt.Errorf("video decode send packet: %d", ret)
@@ -772,7 +817,7 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF
772817 break
773818 }
774819
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 {
776821 ffmpeg.FrameUnref(frame)
777822 return err
778823 }
@@ -784,12 +829,12 @@ func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outF
784829
785830 // encodeAndWriteVideoFrame sends a decoded frame to the encoder and
786831 // 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 {
788833 encFrame := frame
789834 var cleanup func()
790- if p.hwCaps.API == "vaapi" {
835+ if p.hwCaps.API == hwAPINameVAAPI {
791836 var err error
792- encFrame, cleanup, err = p.uploadVideoFrameToVAAPI(frame, encCtx)
837+ encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx)
793838 if err != nil {
794839 return err
795840 }
@@ -823,7 +868,7 @@ func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx uns
823868 return nil
824869 }
825870
826-func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) {
871+func uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) {
827872 if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) {
828873 return frame, func() {}, nil
829874 }
@@ -840,6 +885,7 @@ func (p *pipeline) uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe
840885
841886 cleanup := func() {
842887 if hwFrame != nil {
888+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations.
843889 ffmpeg.FrameFree(unsafe.Pointer(&hwFrame))
844890 }
845891 }
M internal/infrastructure/transcoder/pool.go +2 -2
@@ -39,7 +39,7 @@ func (s *session) Close() error {
3939 }
4040
4141 // ContentType returns the MIME type of the transcoded output.
42-func (s *session) ContentType() string {
42+func (*session) ContentType() string {
4343 return "video/webm"
4444 }
4545
@@ -106,6 +106,6 @@ func (p *sessionPool) closeAll() {
106106
107107 // Close sessions outside the lock.
108108 for _, s := range snapshot {
109- s.Close()
109+ _ = s.Close()
110110 }
111111 }
M internal/infrastructure/transcoder/transcoder.go +4 -4
@@ -84,7 +84,7 @@ func (t *Transcoder) Capabilities() port.HWCapabilities {
8484 // Start begins a transcode session. It spawns a goroutine running the
8585 // FFmpeg pipeline and returns a TranscodeSession whose Read() streams
8686 // 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.
8888 func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) {
8989 if !t.Available() {
9090 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
107107 // Check capacity and register before spawning the goroutine.
108108 if err := t.pool.add(s); err != nil {
109109 cancel()
110- pr.Close()
111- pw.Close()
110+ _ = pr.Close()
111+ _ = pw.Close()
112112 return nil, fmt.Errorf("transcoder: %w", err)
113113 }
114114
@@ -128,7 +128,7 @@ func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[st
128128 defer func() {
129129 if r := recover(); r != nil {
130130 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))
132132 }
133133 }()
134134 p.run(sessionCtx)
M internal/logging/logger_test.go +4 -4
@@ -27,8 +27,8 @@ func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {
2727 t.Fatalf("write before close returned %d, want %d", n, len("first\n"))
2828 }
2929
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)
3232 }
3333
3434 n, err = writer.Write([]byte("second\n"))
@@ -60,8 +60,8 @@ func TestSessionFileWriter_IgnoresExternallyClosedFile(t *testing.T) {
6060 }
6161
6262 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)
6565 }
6666
6767 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
113113
114114 // Handle internal dumb:// scheme URLs - use app logo
115115 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))
121117 return
122118 }
123119
@@ -152,82 +148,87 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
152148 }
153149 }
154150
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)
168155 })
169- glib.IdleAdd(&cb, 0)
156+ return
170157 }
171158
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+}
186163
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 {
187187 log.Debug().
188+ Err(err).
188189 Str("domain", domain).
189190 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+ }
202195
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)
225221 return
226222 }
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)
231232 }
232233
233234 // StoreFromWebKit stores a favicon texture received from WebKit signals.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close