Compare commits

..

No commits in common. "ee67bffbd9c3e5df2733ec679da09df0f4443248" and "1da9317d734d955a00567464379d7de4680e8612" have entirely different histories.

3 changed files with 24 additions and 573 deletions

View File

@ -1,354 +0,0 @@
# Player Module Performance Issues & Fixes
## Current Problems Causing Stuttering
### 1. **Separate Video & Audio Processes (No Sync)**
**Location:** `main.go:9144` (runVideo) and `main.go:9233` (runAudio)
**Problem:**
- Video and audio run in completely separate FFmpeg processes
- No synchronization mechanism between them
- They will inevitably drift apart, causing A/V desync and stuttering
**Current Implementation:**
```go
func (p *playSession) startLocked(offset float64) {
p.runVideo(offset) // Separate process
p.runAudio(offset) // Separate process
}
```
**Why It Stutters:**
- If video frame processing takes too long → audio continues → desync
- If audio buffer underruns → video continues → desync
- No feedback loop to keep them in sync
---
### 2. **Audio Buffer Too Small**
**Location:** `main.go:8960` (audio context) and `main.go:9274` (chunk size)
**Problem:**
```go
// Audio context with tiny buffer (42ms at 48kHz)
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
// Tiny read chunks (21ms of audio)
chunk := make([]byte, 4096)
```
**Why It Stutters:**
- 21ms chunks mean we need to read 47 times per second
- Any delay > 21ms causes audio dropout/stuttering
- 2048 sample buffer gives only 42ms protection against underruns
- Modern systems need 100-200ms buffers for smooth playback
---
### 3. **Volume Processing in Hot Path**
**Location:** `main.go:9294-9318`
**Problem:**
```go
// Processes volume on EVERY audio chunk read
for i := 0; i+1 < n; i += 2 {
sample := int16(binary.LittleEndian.Uint16(tmp[i:]))
amp := int(float64(sample) * gain)
// ... clamping ...
binary.LittleEndian.PutUint16(tmp[i:], uint16(int16(amp)))
}
```
**Why It Stutters:**
- CPU-intensive per-sample processing
- Happens 47 times/second with tiny chunks
- Blocks the audio read loop
- Should use FFmpeg's volume filter or hardware mixing
---
### 4. **Video Frame Pacing Issues**
**Location:** `main.go:9200-9203`
**Problem:**
```go
if delay := time.Until(nextFrameAt); delay > 0 {
time.Sleep(delay)
}
nextFrameAt = nextFrameAt.Add(frameDur)
```
**Why It Stutters:**
- `time.Sleep()` is not precise (can wake up late)
- Cumulative drift: if one frame is late, all future frames shift
- No correction mechanism if we fall behind
- UI thread delays from `DoFromGoroutine` can cause frame drops
---
### 5. **UI Thread Blocking**
**Location:** `main.go:9207-9215`
**Problem:**
```go
// Every frame waits for UI thread to be available
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
p.img.Image = frame
p.img.Refresh()
}, false)
```
**Why It Stutters:**
- If UI thread is busy, frame updates queue up
- Can cause video to appear choppy even if FFmpeg is delivering smoothly
- No frame dropping mechanism if UI can't keep up
---
### 6. **Frame Allocation on Every Frame**
**Location:** `main.go:9205-9206`
**Problem:**
```go
// Allocates new frame buffer 24-60 times per second
frame := image.NewRGBA(image.Rect(0, 0, p.targetW, p.targetH))
utils.CopyRGBToRGBA(frame.Pix, buf)
```
**Why It Stutters:**
- Memory allocation on every frame causes GC pressure
- Extra copy operation adds latency
- Could reuse buffers or use ring buffer
---
## Recommended Fixes (Priority Order)
### Priority 1: Increase Audio Buffers (Quick Fix)
**Change `main.go:8960`:**
```go
// OLD: 2048 samples = 42ms
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
// NEW: 8192 samples = 170ms (more buffer = smoother playback)
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 8192)
```
**Change `main.go:9274`:**
```go
// OLD: 4096 bytes = 21ms
chunk := make([]byte, 4096)
// NEW: 16384 bytes = 85ms per chunk
chunk := make([]byte, 16384)
```
**Expected Result:** Audio stuttering should improve significantly
---
### Priority 2: Use FFmpeg for Volume Control
**Change `main.go:9238-9247`:**
```go
// Add volume filter to FFmpeg command instead of processing in Go
volumeFilter := ""
if p.muted || p.volume <= 0 {
volumeFilter = "-af volume=0"
} else if math.Abs(p.volume - 100) > 0.1 {
volumeFilter = fmt.Sprintf("-af volume=%.2f", p.volume/100.0)
}
cmd := exec.Command(platformConfig.FFmpegPath,
"-hide_banner", "-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
"-vn",
"-ac", fmt.Sprintf("%d", channels),
"-ar", fmt.Sprintf("%d", sampleRate),
volumeFilter, // Let FFmpeg handle volume
"-f", "s16le",
"-",
)
```
**Remove volume processing loop (lines 9294-9318):**
```go
// Simply write chunks directly
localPlayer.Write(chunk[:n])
```
**Expected Result:** Reduced CPU usage, smoother audio
---
### Priority 3: Use Single FFmpeg Process with A/V Sync
**Conceptual Change:**
Instead of separate video/audio processes, use ONE FFmpeg process that:
1. Outputs video frames to one pipe
2. Outputs audio to another pipe (or use `-f matroska` with demuxing)
3. Maintains sync internally
**Pseudocode:**
```go
cmd := exec.Command(platformConfig.FFmpegPath,
"-ss", fmt.Sprintf("%.3f", offset),
"-i", p.path,
// Video stream
"-map", "0:v:0",
"-f", "rawvideo",
"-pix_fmt", "rgb24",
"-r", fmt.Sprintf("%.3f", p.fps),
"pipe:4", // Video to fd 4
// Audio stream
"-map", "0:a:0",
"-ac", "2",
"-ar", "48000",
"-f", "s16le",
"pipe:5", // Audio to fd 5
)
```
**Expected Result:** Perfect A/V sync, no drift
---
### Priority 4: Frame Buffer Reuse
**Change `main.go:9205-9206`:**
```go
// Reuse frame buffers instead of allocating every frame
type framePool struct {
pool sync.Pool
}
func (p *framePool) get(w, h int) *image.RGBA {
if img := p.pool.Get(); img != nil {
return img.(*image.RGBA)
}
return image.NewRGBA(image.Rect(0, 0, w, h))
}
func (p *framePool) put(img *image.RGBA) {
// Clear pixel data
for i := range img.Pix {
img.Pix[i] = 0
}
p.pool.Put(img)
}
// In video loop:
frame := framePool.get(p.targetW, p.targetH)
utils.CopyRGBToRGBA(frame.Pix, buf)
// ... use frame ...
// Note: can't return to pool if UI is still using it
```
**Expected Result:** Reduced GC pressure, smoother frame delivery
---
### Priority 5: Adaptive Frame Timing
**Change `main.go:9200-9203`:**
```go
// Track actual vs expected time to detect drift
now := time.Now()
behind := now.Sub(nextFrameAt)
if behind < 0 {
// We're ahead, sleep until next frame
time.Sleep(-behind)
} else if behind > frameDur*2 {
// We're way behind (>2 frames), skip this frame
logging.Debug(logging.CatFFMPEG, "dropping frame, %.0fms behind", behind.Seconds()*1000)
nextFrameAt = now
continue
} else {
// We're slightly behind, catchup gradually
nextFrameAt = now.Add(frameDur / 2)
}
nextFrameAt = nextFrameAt.Add(frameDur)
```
**Expected Result:** Better handling of temporary slowdowns, adaptive recovery
---
## Testing Checklist
After each fix, test:
- [ ] 24fps video plays smoothly
- [ ] 30fps video plays smoothly
- [ ] 60fps video plays smoothly
- [ ] Audio doesn't stutter
- [ ] A/V sync maintained over 30+ seconds
- [ ] Seeking doesn't cause prolonged stuttering
- [ ] CPU usage is reasonable (<20% for playback)
- [ ] Works on both Linux and Windows
- [ ] Works with various codecs (H.264, H.265, VP9)
- [ ] Volume control works smoothly
- [ ] Pause/resume doesn't cause issues
---
## Performance Monitoring
Add instrumentation to measure:
```go
// Video frame timing
frameDeliveryTime := time.Since(frameReadStart)
if frameDeliveryTime > frameDur*1.5 {
logging.Debug(logging.CatFFMPEG, "slow frame delivery: %.1fms (target: %.1fms)",
frameDeliveryTime.Seconds()*1000,
frameDur.Seconds()*1000)
}
// Audio buffer health
if audioBufferFillLevel < 0.3 {
logging.Debug(logging.CatFFMPEG, "audio buffer low: %.0f%%", audioBufferFillLevel*100)
}
```
---
## Alternative: Use External Player Library
If these tweaks don't achieve smooth playback, consider:
1. **mpv library** (libmpv) - Industry standard, perfect A/V sync
2. **FFmpeg's ffplay** code - Reference implementation
3. **VLC libvlc** - Proven playback engine
These handle all the complex synchronization automatically.
---
## Summary
**Root Causes:**
1. Separate video/audio processes with no sync
2. Tiny audio buffers causing underruns
3. CPU waste on per-sample volume processing
4. Frame timing drift with no correction
5. UI thread blocking frame updates
**Quick Wins (30 min):**
- Increase audio buffers (Priority 1)
- Move volume to FFmpeg (Priority 2)
**Proper Fix (2-4 hours):**
- Single FFmpeg process with A/V muxing (Priority 3)
- Frame buffer pooling (Priority 4)
- Adaptive timing (Priority 5)
**Expected Final Result:**
- Smooth playback at all frame rates
- Rock-solid A/V sync
- Low CPU usage
- No stuttering or dropouts

View File

@ -75,7 +75,6 @@ func buildAuthorView(state *appState) fyne.CanvasObject {
}
func buildVideoClipsTab(state *appState) fyne.CanvasObject {
state.authorVideoTSPath = strings.TrimSpace(state.authorVideoTSPath)
list := container.NewVBox()
listScroll := container.NewVScroll(list)
@ -153,7 +152,6 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
state.authorClips = []authorClip{}
state.authorChapters = nil
state.authorChapterSource = ""
state.authorVideoTSPath = ""
rebuildList()
state.updateAuthorSummary()
})
@ -598,9 +596,7 @@ func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
func authorSummary(state *appState) string {
summary := "Ready to generate:\n\n"
if state.authorVideoTSPath != "" {
summary += fmt.Sprintf("VIDEO_TS: %s\n", filepath.Base(filepath.Dir(state.authorVideoTSPath)))
} else if len(state.authorClips) > 0 {
if len(state.authorClips) > 0 {
summary += fmt.Sprintf("Videos: %d\n", len(state.authorClips))
for i, clip := range state.authorClips {
summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration)
@ -624,9 +620,6 @@ func authorSummary(state *appState) string {
summary += fmt.Sprintf("Disc Size: %s\n", state.authorDiscSize)
summary += fmt.Sprintf("Region: %s\n", state.authorRegion)
summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio)
if outPath := authorDefaultOutputPath(state.authorOutputType, authorOutputTitle(state), authorSummaryPaths(state)); outPath != "" {
summary += fmt.Sprintf("Output Path: %s\n", outPath)
}
if state.authorTitle != "" {
summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle)
}
@ -703,34 +696,6 @@ func authorTotalDuration(state *appState) float64 {
return 0
}
func authorSummaryPaths(state *appState) []string {
if state.authorVideoTSPath != "" {
return []string{state.authorVideoTSPath}
}
if len(state.authorClips) > 0 {
paths := make([]string, 0, len(state.authorClips))
for _, clip := range state.authorClips {
paths = append(paths, clip.Path)
}
return paths
}
if state.authorFile != nil {
return []string{state.authorFile.Path}
}
return nil
}
func authorOutputTitle(state *appState) string {
title := strings.TrimSpace(state.authorTitle)
if title != "" {
return title
}
if state.authorVideoTSPath != "" {
return filepath.Base(filepath.Dir(state.authorVideoTSPath))
}
return defaultAuthorTitle(authorSummaryPaths(state))
}
func authorTargetBitrateKbps(discSize string, totalSeconds float64) int {
if totalSeconds <= 0 {
return 0
@ -1034,19 +999,6 @@ func (s *appState) setAuthorProgress(percent float64) {
}
func (s *appState) startAuthorGeneration() {
if s.authorVideoTSPath != "" {
title := authorOutputTitle(s)
outputPath := authorDefaultOutputPath("iso", title, []string{s.authorVideoTSPath})
if outputPath == "" {
dialog.ShowError(fmt.Errorf("failed to resolve output path"), s.window)
return
}
if err := s.addAuthorVideoTSToQueue(s.authorVideoTSPath, title, outputPath, true); err != nil {
dialog.ShowError(err, s.window)
}
return
}
paths, primary, err := s.authorSourcePaths()
if err != nil {
dialog.ShowError(err, s.window)
@ -1094,12 +1046,28 @@ func (s *appState) promptAuthorOutput(paths []string, region, aspect, title stri
outputType = "dvd"
}
outputPath := authorDefaultOutputPath(outputType, title, paths)
if outputType == "iso" {
s.generateAuthoring(paths, region, aspect, title, outputPath, true)
dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) {
if err != nil || writer == nil {
return
}
path := writer.URI().Path()
writer.Close()
if !strings.HasSuffix(strings.ToLower(path), ".iso") {
path += ".iso"
}
s.generateAuthoring(paths, region, aspect, title, path, true)
}, s.window)
return
}
s.generateAuthoring(paths, region, aspect, title, outputPath, false)
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
discRoot := filepath.Join(uri.Path(), authorOutputFolderName(title, paths))
s.generateAuthoring(paths, region, aspect, title, discRoot, false)
}, s.window)
}
func authorWarnings(state *appState) []string {
@ -1199,66 +1167,6 @@ func authorOutputFolderName(title string, paths []string) string {
return name
}
func authorDefaultOutputDir(outputType string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
home = "."
}
dir := filepath.Join(home, "Videos")
if strings.EqualFold(outputType, "iso") {
return filepath.Join(dir, "ISO_Convert")
}
return filepath.Join(dir, "DVD_Convert")
}
func authorDefaultOutputPath(outputType, title string, paths []string) string {
outputType = strings.ToLower(strings.TrimSpace(outputType))
if outputType == "" {
outputType = "dvd"
}
baseDir := authorDefaultOutputDir(outputType)
name := strings.TrimSpace(title)
if name == "" {
name = defaultAuthorTitle(paths)
}
name = sanitizeForPath(name)
if name == "" {
name = "dvd_output"
}
if outputType == "iso" {
return uniqueFilePath(filepath.Join(baseDir, name+".iso"))
}
return uniqueFolderPath(filepath.Join(baseDir, name))
}
func uniqueFolderPath(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
for i := 1; i < 1000; i++ {
tryPath := fmt.Sprintf("%s-%d", path, i)
if _, err := os.Stat(tryPath); os.IsNotExist(err) {
return tryPath
}
}
return fmt.Sprintf("%s-%d", path, time.Now().Unix())
}
func uniqueFilePath(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
for i := 1; i < 1000; i++ {
tryPath := fmt.Sprintf("%s-%d%s", base, i, ext)
if _, err := os.Stat(tryPath); os.IsNotExist(err) {
return tryPath
}
}
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
}
func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) {
if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, true); err != nil {
dialog.ShowError(err, s.window)
@ -1331,34 +1239,6 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu
return nil
}
func (s *appState) addAuthorVideoTSToQueue(videoTSPath, title, outputPath string, startNow bool) error {
if s.jobQueue == nil {
return fmt.Errorf("queue not initialized")
}
job := &queue.Job{
Type: queue.JobTypeAuthor,
Title: fmt.Sprintf("Author ISO: %s", title),
Description: fmt.Sprintf("VIDEO_TS -> %s", utils.ShortenMiddle(filepath.Base(outputPath), 40)),
InputFile: videoTSPath,
OutputFile: outputPath,
Config: map[string]interface{}{
"videoTSPath": videoTSPath,
"outputPath": outputPath,
"makeISO": true,
"title": title,
},
}
s.resetAuthorLog()
s.setAuthorStatus("Queued authoring job...")
s.setAuthorProgress(0)
s.jobQueue.Add(job)
if startNow && !s.jobQueue.IsRunning() {
s.jobQueue.Start()
}
return nil
}
func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, region, aspect, title, outputPath string, makeISO bool, clips []authorClip, chapters []authorChapter, treatAsChapters bool, logFn func(string), progressFn func(float64)) error {
workDir, err := os.MkdirTemp(utils.TempDir(), "videotools-author-")
if err != nil {
@ -1496,58 +1376,6 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres
if cfg == nil {
return fmt.Errorf("author job config missing")
}
if videoTSPath := strings.TrimSpace(toString(cfg["videoTSPath"])); videoTSPath != "" {
outputPath := toString(cfg["outputPath"])
title := toString(cfg["title"])
if err := ensureAuthorDependencies(true); err != nil {
return err
}
logFile, logPath, logErr := createAuthorLog([]string{videoTSPath}, outputPath, true, "", "", title)
if logErr != nil {
logging.Debug(logging.CatSystem, "author log open failed: %v", logErr)
} else {
job.LogPath = logPath
defer logFile.Close()
}
appendLog := func(line string) {
if logFile != nil {
fmt.Fprintln(logFile, line)
}
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.appendAuthorLog(line)
}, false)
}
}
updateProgress := func(percent float64) {
progressCallback(percent)
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.setAuthorProgress(percent)
}, false)
}
}
appendLog(fmt.Sprintf("Authoring ISO from VIDEO_TS: %s", videoTSPath))
tool, args, err := buildISOCommand(outputPath, videoTSPath, title)
if err != nil {
return err
}
appendLog(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
updateProgress(10)
if err := runCommandWithLogger(ctx, tool, args, appendLog); err != nil {
return err
}
updateProgress(100)
appendLog("ISO creation completed successfully.")
return nil
}
rawPaths, _ := cfg["paths"].([]interface{})
var paths []string
for _, p := range rawPaths {

31
main.go
View File

@ -926,7 +926,6 @@ type appState struct {
authorProgress float64
authorProgressBar *widget.ProgressBar
authorStatusLabel *widget.Label
authorVideoTSPath string
// Subtitles module state
subtitleVideoPath string
@ -8958,9 +8957,7 @@ var audioCtxGlobal struct {
func getAudioContext(sampleRate, channels, bytesPerSample int) (*oto.Context, error) {
audioCtxGlobal.once.Do(func() {
// Increased from 2048 (42ms) to 8192 (170ms) for smoother playback
// Larger buffer prevents audio stuttering and underruns
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 8192)
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
})
return audioCtxGlobal.ctx, audioCtxGlobal.err
}
@ -9274,10 +9271,8 @@ func (p *playSession) runAudio(offset float64) {
go func() {
defer cmd.Process.Kill()
defer localPlayer.Close()
// Increased from 4096 (21ms) to 16384 (85ms) for smoother playback
// Larger chunks reduce read frequency and improve performance
chunk := make([]byte, 16384)
tmp := make([]byte, 16384)
chunk := make([]byte, 4096)
tmp := make([]byte, 4096)
loggedFirst := false
for {
select {
@ -9520,22 +9515,12 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
// If in author module, add video clips
if s.active == "author" {
var videoPaths []string
var videoTSPath string
for _, uri := range items {
if uri.Scheme() != "file" {
continue
}
path := uri.Path()
if info, err := os.Stat(path); err == nil && info.IsDir() {
if strings.EqualFold(filepath.Base(path), "VIDEO_TS") {
videoTSPath = path
break
}
videoTSChild := filepath.Join(path, "VIDEO_TS")
if info, err := os.Stat(videoTSChild); err == nil && info.IsDir() {
videoTSPath = videoTSChild
break
}
videos := s.findVideoFiles(path)
videoPaths = append(videoPaths, videos...)
} else if s.isVideoFile(path) {
@ -9543,15 +9528,6 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
}
}
if videoTSPath != "" {
s.authorVideoTSPath = videoTSPath
s.authorClips = nil
s.authorFile = nil
s.authorOutputType = "iso"
s.showAuthorView()
return
}
if len(videoPaths) == 0 {
logging.Debug(logging.CatUI, "no valid video files in dropped items")
return
@ -12585,6 +12561,7 @@ func buildCompareView(state *appState) fyne.CanvasObject {
return container.NewBorder(topBar, bottomBar, nil, nil, content)
}
// buildThumbView creates the thumbnail generation UI
func buildThumbView(state *appState) fyne.CanvasObject {
thumbColor := moduleColor("thumb")