Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

speaker.Init occupies 5% of the CPU, even if no audio is played #137

Open
zwjjiaozhu opened this issue Dec 24, 2023 · 19 comments
Open

speaker.Init occupies 5% of the CPU, even if no audio is played #137

zwjjiaozhu opened this issue Dec 24, 2023 · 19 comments

Comments

@zwjjiaozhu
Copy link

os:macos 12.6

When the audio is not playing, I see that the idle wake-up thread is constantly increasing in the activity monitor software on the Mac
I don't know why it doesn't play audio and still takes up CPU to calculate
Is there any way to solve it? Thank you

image

@zwjjiaozhu zwjjiaozhu changed the title speaker.Init occupies 5% of the CPU, even if no audio is played #229 speaker.Init occupies 5% of the CPU, even if no audio is played Dec 24, 2023
@MarkKremer
Copy link
Contributor

MarkKremer commented Dec 31, 2023

Hi there 👋

I'm not entirely sure this is fixable. But we can debug a bit to find where the CPU load is coming from. :) Could you run the following programs and report your CPU usage?

  1. Playing silence through Beep.
package main

import (
	"fmt"
	"time"

	"github.com/gopxl/beep/speaker"
)

func main() {
	fmt.Println("[Speaker test through Beep]")
	
	err := speaker.Init(44100, 44100/30)
	if err != nil {
		return
	}

	time.Sleep(time.Second * 60)
}
  1. Using the underlying audio library (Oto) directly.
package main

import (
	"fmt"
	"time"

	"github.com/ebitengine/oto/v3"
)

func main() {
	fmt.Println("[Speaker test through Oto with player]")

	context, readyChan, err := oto.NewContext(&oto.NewContextOptions{
		SampleRate:   44100,
		ChannelCount: 2,
		Format:       oto.FormatSignedInt16LE,
		BufferSize:   time.Second / 30,
	})
	if err != nil {
		panic(err)
	}
	<-readyChan

	player := context.NewPlayer(&silenceReader{})
	player.Play()

	fmt.Println("Playing silence for 60s...")
	time.Sleep(time.Second * 60)
}

type silenceReader struct {
}

func (s *silenceReader) Read(p []byte) (n int, err error) {
	for i := range p {
		p[i] = 0
	}
	return len(p), nil
}
  1. Using Oto again but without initializing a speaker.
package main

import (
	"fmt"
	"time"

	"github.com/ebitengine/oto/v3"
)

func main() {
	fmt.Println("[Speaker test through Oto without player]")

	context, readyChan, err := oto.NewContext(&oto.NewContextOptions{
		SampleRate:   44100,
		ChannelCount: 2,
		Format:       oto.FormatSignedInt16LE,
		BufferSize:   time.Second / 30,
	})
	if err != nil {
		panic(err)
	}
	<-readyChan

	fmt.Println("Playing silence for 60s...")
	time.Sleep(time.Second * 60)
}

Do you know if the 5% CPU is of a single core or over all your cores? How does it compare to playing an audio file through another app?

@MarkKremer
Copy link
Contributor

MarkKremer commented Jan 3, 2024

OK I've looked further into this and it may be fixable by suspending Oto's context and/or player. But I would still appreciate your response on my previous comment because that might lead to performance improvements when sound is playing.

Edit: suspending the context at the right time may actually be quite difficult. Hmmmm

@MarkKremer
Copy link
Contributor

Looked even deeper. On Darwin resuming the audio context could fail because Siri or something else is blocking it. I don't think auto-suspending the context is a good idea in that case. I don't know how that would impact a game for example. My profiler says that the bulk of the CPU load is in sending the samples away to the driver (on Linux at least). My current thought is to expose the Suspend() and Resume() functionality so those can be called manually.

@thiagokokada
Copy link

thiagokokada commented Jan 16, 2024

Also suffering from a similar issue. What I observed is that:

// File:
// assets/notification_1.ogg: Ogg data, Vorbis audio, mono, 48000 Hz, ~239920 bps
// Around 5% of CPU usage in a Macbook M1 Pro
speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/8))
// Around 2% of CPU usage, and high lag (~500ms), but for my purposes this amount of lag is acceptable
speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/2))
// Around 1.5% of CPU usage
speaker.Init(format.SampleRate, format.SampleRate.N(time.Second))
// Around 1.2% of CPU usage
speaker.Init(format.SampleRate, format.SampleRate.N(time.Second*2))

As it is clear in the example above, I am using beep to play notification sounds so even 2% seems excessive. I play 2 sounds every 20 minutes, but the program constantly consumes the above CPU %. The remaining of my program (if I don't call speaker.Init()) uses 0% CPU, so all CPU usage is coming from beep.

My current thought is to expose the Suspend() and Resume() functionality so those can be called manually.

It would definitely work for my application if I could call Suspend() and Resume() manually.

I tried to manually call Stop() and Init() again, but at least in Darwin this didn't result in any improvement in CPU usage.

@MarkKremer
Copy link
Contributor

Would you be able to run a CPU profiler on it?

PR's for the Suspend/Resume functionality are welcomed.

@thiagokokada
Copy link

Would you be able to run a CPU profiler on it?

PR's for the Suspend/Resume functionality are welcomed.

I am new to Go and have zero idea on how to run a profiler, but if you have any good documentation on how to do so I can try.

@MarkKremer
Copy link
Contributor

To be honest I just click on the profile button in the Goland IDE. But Go has easy and good profiling tools as well and it can export the profiler data: https://go.dev/blog/pprof

It's possible to add the to-be-profiled code to a test and run the test command with the correct flags, or add the profiling code to an existing program like so:

package main

import (
	"flag"
	"fmt"
	"log"
	"os"
	"runtime/pprof"
	"time"

	"github.com/gopxl/beep/speaker"
)

var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")

func main() {
	flag.Parse()
	if *cpuprofile != "" {
		f, err := os.Create(*cpuprofile)
		if err != nil {
			log.Fatal(err)
		}
		if err := pprof.StartCPUProfile(f); err != nil {
			log.Fatal(err)
		}
		defer pprof.StopCPUProfile()
	}

	fmt.Println("[Speaker test through Beep]")
	err := speaker.Init(44100, 44100/30)
	if err != nil {
		log.Fatal(err)
	}

	time.Sleep(time.Second * 10)
}

This can be run using go run main.go -cpuprofile beep.prof. This will create a file beep.prof containing the profile data. This can then be interpreted using the go tool pprof beep.prof command. If you're interested I would read the article for yourself. It's pretty interesting and explains how to use pprof.

beep.prof should be a couple KB. Could you zip it and attach it to a comment?

@MarkKremer
Copy link
Contributor

MarkKremer commented Jan 17, 2024

Just for fun. This is what it looks like for me after running go tool pprof beep.prof and then the web command.

You'll see that most time is spent in the cgocall which is called by _Cfunc_snd_pcm_writei (which writes the samples to the driver ALSA used on Linux).

@thiagokokada
Copy link

thiagokokada commented Jan 17, 2024

This can be run using go run main.go -cpuprofile beep.prof. This will create a file beep.prof containing the profile data. This can then be interpreted using the go tool pprof beep.prof command. If you're interested I would read the article for yourself. It's pretty interesting and explains how to use pprof.

Here you go: beep.prof.zip.

@thiagokokada
Copy link

You'll see that most time is spent in the cgocall which is called by _Cfunc_snd_pcm_writei (which writes the samples to the driver ALSA used on Linux).

For my particular case, since most of time the program is doing absolutely nothing (just waiting for a ticker to tick every 20 minutes to actually do something), I would still like to have the possibility to just suspend the context of the speaker so it can use ~0% CPU, until I need to resume the speaker to do work again.

So even if the CPU usage could be reduced, I would appreciate the possibility of suspending/resuming the context.

@MarkKremer
Copy link
Contributor

Definitely!

@MarkKremer
Copy link
Contributor

MarkKremer commented Jan 24, 2024

speaker.Suspend and speaker.Resume are now in the main branch. We'll probably release on the first of February.

speaker.Suspend brings the CPU usage to 0% on my machine. I'll leave this issue open for now to dive into the non-suspended CPU usage another time. Thank you for your help with supplying a profiler report @thiagokokada.

@thiagokokada
Copy link

speaker.Suspend and speaker.Resume are now in the main branch. We'll probably release on the first of February.

speaker.Suspend brings the CPU usage to 0% on my machine. I'll leave this issue open for now to dive into the non-suspended CPU usage another time. Thank you for your help with supplying a profiler report @thiagokokada.

Perfect, thank you. I am testing this in my application and it is working perfectly.

Also I really like that I can combine the beep.Callback(speaker.Suspend()) to automatically suspend the speakers after the sound finishes playing. Something like:

func speakerResume() {
	err := speaker.Resume()
	if err != nil {
		log.Printf("Error while resuming speaker: %v\n", err)
	}
}

func speakerSuspend() {
	err := speaker.Suspend()
	if err != nil {
		log.Printf("Error while suspending speaker: %v\n", err)
	}
}

func PlaySound() {
	speakerResume()
	speaker.Play(beep.Seq(
		buffer.Streamer(0, buffer.Len()),
		beep.Callback(speakerSuspend),
	))
}

@MarkKremer
Copy link
Contributor

Cool!

Just a sidenote. Suspend will pretty immediately stop the speaker from sending samples to the driver while the callback gets called after all samples are consumed by the speaker, but they are still in the speaker package's buffer and haven't reached the driver yet. So it may cut off the last bufferSize of samples. If you notice it, just add a sleep of the same duration as the buffer in the callback before suspending.

@MarkKremer
Copy link
Contributor

MarkKremer commented Jan 24, 2024

Question for everyone: would it be desirable if speaker.Suspend() stops consuming samples but waits with suspending until the whole buffer is send to the driver? So suspend will mean "stop consuming" instead of "stop sending to the driver". (I'm not sure if this is possible with Oto currently but lets assume it is for now)

@thiagokokada
Copy link

Question for everyone: would it be desirable if speaker.Suspend() stops consuming samples but waits with suspending until the whole buffer is send to the driver? So suspend will mean "stop consuming" instead of "stop sending to the driver". (I'm not sure if this is possible with Oto currently but lets assume it is for now)

I think as long it is documented (with examples preferably), for me the current behavior is fine, and probably more flexible too.

It is not like adding time.Sleep() before speaker.Suspend() was difficult for me to do so, and also there is some flexibility here (e.g.: I can add a bigger time.Sleep() than the buffer size, since I would say the difference between suspending in 100ms or 1000ms is not that irrelevant for battery in most cases).

@MarkKremer
Copy link
Contributor

I don't see the point of the extra flexibility. If you need more sleep you could always add it. But are there reasons to not need any sleep?

Manually sleeping isn't the most difficult but anything that makes the API more intuitive seems like a good thing. (and I can decide later if it's worth the effort). But maybe I'm a bit of a perfectionist. I do really appreciate your input. Tnx!

@MarkKremer
Copy link
Contributor

Related to the CPU usage question of the author: ebitengine/oto#229

@MarkKremer
Copy link
Contributor

@thiagokokada New version got released just now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants