detached a57cae7

feat(cef): wire browser audio output through PipeWire

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