detached 02f4a19

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

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

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