detached a57cae7

feat(cef): wire browser audio output through PipeWire

Move audio output construction into bootstrap and route CEF audio streams through the new output port. Clean up the related transcoding, tests, and lint fallout so the branch passes verification.
commit
a57cae7e25a6ade58b090b04e8aec5b47482ce55
author
brice · · 2026-04-11 07:15 UTC
parent
eb29122
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
37 files · +2620 -473
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.
@@ -112,7 +117,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
112117 // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
113118 func wireEngine(
114119 ctx context.Context, eng *Engine, cfg config.CEFEngineConfig, transcodingCfg config.TranscodingConfig,
115- windowlessFrameRate int32, logger *zerolog.Logger,
120+ windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
116121 ) (*Engine, error) {
117122 gl, err := newGLLoader()
118123 if err != nil {
@@ -150,6 +155,7 @@ func wireEngine(
150155 windowlessFrameRate: windowlessFrameRate,
151156 enableContextMenuHandler: cfg.EnableContextMenuHandler,
152157 transcoder: mediaTranscoder,
158+ audioOutputFactory: audioFactory,
153159 })
154160 pool := newWebViewPool(factory)
155161
@@ -273,22 +279,6 @@ func detectHiDPIScale(logger *zerolog.Logger) int32 {
273279 return 1
274280 }
275281
276-// detectMonitorRefreshRate queries the primary GDK monitor for its refresh rate.
277-// Returns the rate in Hz (e.g., 120 for a 120Hz display), or 0 if detection fails.
278-func detectMonitorRefreshRate(logger *zerolog.Logger) int32 {
279- mon := getPrimaryMonitor()
280- if mon == nil {
281- return 0
282- }
283- milliHz := mon.GetRefreshRate()
284- if milliHz <= 0 {
285- return 0
286- }
287- hz := int32((milliHz + 500) / 1000) // round to nearest Hz
288- logger.Info().Int32("refresh_rate_hz", hz).Msg("cef: detected monitor refresh rate")
289- return hz
290-}
291-
292282 // appendIfMissing appends flag to args if it's not already present.
293283 func appendIfMissing(args []string, flag string) []string {
294284 for _, a := range args {
M internal/infrastructure/cef/engine_init_test.go +10 -0
@@ -4,6 +4,7 @@ import (
44 "path/filepath"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/infrastructure/audio/factory"
78 "github.com/bnema/dumber/internal/infrastructure/config"
89 "github.com/rs/zerolog"
910 "github.com/stretchr/testify/require"
@@ -38,3 +39,12 @@ func TestPrepareCEFSettings_SetsRootCachePath(t *testing.T) {
3839
3940 require.Equal(t, filepath.Join(homeDir, ".config", "cef_user_data"), settings.RootCachePath)
4041 }
42+
43+// TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
44+// factory can be created and produces working streams. This tests the runtime
45+// wiring decision to create the factory during CEF engine setup.
46+func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
47+ audioFactory := factory.NewAudioOutputFactory()
48+
49+ require.NotNil(t, audioFactory, "Audio factory should not be nil")
50+}
M internal/infrastructure/cef/factory.go +19 -9
@@ -25,6 +25,7 @@ type WebViewFactory struct {
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/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