-
Notifications
You must be signed in to change notification settings - Fork 111
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
Fix gapless playback #269
Fix gapless playback #269
Conversation
Codecov Report
@@ Coverage Diff @@
## master #269 +/- ##
==========================================
+ Coverage 96.64% 96.65% +0.01%
==========================================
Files 13 13
Lines 1191 1197 +6
==========================================
+ Hits 1151 1157 +6
Misses 40 40
Continue to review full report at Codecov.
|
Weird, this fix doesn't seem to be working when running from E: I'll have to do more testing, it seems to only work with my specific setup, weird thing. E2: I just stopped reproducing it on my own machine, gapless play back isn't working once again. |
Ok, apparently libspotify sends one of those invalid packets before sending track end signal... is there any way to stop that other than measuring buffer size? |
This has been eluding me for years, this is an awesome find! Perhaps you could delay everything by one buffer by queuing it in our code (not in GStreamer) and then just discard the last one if this bad behavior is consistent? |
@adamcik Thank you! I thought of this solution, and I think it should work well. I'll ask first, I don't see any access to |
We know this should be a singleton, so I think we can get away with doing this module scoped. At least it's good enough to get things going and see if it works? |
Too many commits for this little thing, excuse my stupidity. At least it's working well now, I've been playing music and radios all day, I'm not getting any errors and transitions are good. @adamcik I'd like to request a review. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added some suggestions, and mostly questions about things I don't know the answer to off the top of my head. The one that I would like addressed is just adding some more comments for future readers of the code, rest I'm not as sure of.
It's been quite some time since I've been deep in the audio code for mopidy, at some point my attempts at getting gapless working and unblocking plugable outputs ground to a halt likely due to what this PR fixes.
mopidy_spotify/playback.py
Outdated
@@ -15,6 +15,9 @@ | |||
# Extra log level with lower importance than DEBUG=10 for noisy debug logging | |||
TRACE_LOG_LEVEL = 5 | |||
|
|||
# Last audio data sent to the buffer, currently on hold |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would put in a small explanation about why we need to do this, it's not obvious without the context of this PR. So perhaps a short sentence with a link to this PR or a self contained paragraph?
mopidy_spotify/playback.py
Outdated
@@ -78,6 +83,7 @@ def change_track(self, track): | |||
sp_track.load(self._timeout) | |||
self.backend._session.player.load(sp_track) | |||
self.backend._session.player.play() | |||
_held_buffer = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jodal probably knows the answer to this better than me, but when would the session start being able to fill this value async? I.e. should we reset it before calling load/play, or does it depend on the current state of the session?
Could protecting this global with a lock be reasonable, or are we just asking for deadlocks if we aren't careful?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
music_delivery
will start firing as soon as player.play()
is called and so a race sounds possible. Is there any issue with resetting it along with the other stuff before the try
block?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should be fine if placed before. My mistake I didn't think it could happen. I'll correct this.
if _held_buffer: | ||
consumed = audio_actor.emit_data(_held_buffer).get() | ||
else: | ||
consumed = True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if this should be true when there isn't a held buffer, as a follow on effect would be increasing the duration incorrectly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I set it as true, because we take the current buffer and put it on hold, which (if my understanding of frame consumption is right) means we did consume the frames — even if we'll push it to Mopidy later.
On the other hand, I'm not sure if the buffer_timestamp
is increased correctly if its done before emitting data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does seem weird to attach what we know is the wrong timestamp to the buffer we are emitting to GST (although the error will be very small). Should we be doing something more like buffer_timestamp.increase(_held_buffer.duration)
?
EDIT: Sorry, confused myself. The timestamp attached to the buffer is obviously correct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is fine. It doesn't matter when we increment buffer_timestamp
as long as it's before we use in the next delivery.
Also, if you feel your git skills are up to it, feel free to tidy up the commits by rebasing/squashing etc (but no pressure if you feel that would just create a mess). We often do this type of cleanup on our own changes to keep the history tidy. As we'll often go through a similar process as you have in this PR finding more things to fix/adjust... |
@jodal can/should the empty buffer suppression be moved to pyspotify, or should we document change things so we could do a |
Nice! This is a great find and I don't understand how I missed / forgot about this PR. It prompted me to find https://stackoverflow.com/questions/26014520/libspotify-c-sending-zeros-at-the-end-of-track which also talks about that final data buffer of 22050 zeros. I think your solution works great and I am happy to go with it. Catching this funny final music_delivery case as it happens is also an option. We could then avoid emitting the large empty buffer which is the ultimate cause of our nasty gap. Something like:
The above works with or without the addition of That Stack Overflow post also talks about how the duration is pushed beyond the length of the track by this fake final delivery and otherwise may come up a little short. So another option might be detect when the EDIT:
|
@kingosticks Your proposed solution is what I thought of at first and used as a workaround, but I wasn't sure if this condition would always trigger only when needed. I think buffer holding is better for this reason. Also, sorry for stalling the PR for the time, I hope the added comments should be okay now. By the way, because I was away and my laptop had weird issues with Python, I had to use plain Spotify for a week. Their clients are so inconsistent and broken. Mopidy is really a bless. Thank you for making this project ❤️ |
mopidy_spotify/playback.py
Outdated
@@ -78,6 +83,7 @@ def change_track(self, track): | |||
sp_track.load(self._timeout) | |||
self.backend._session.player.load(sp_track) | |||
self.backend._session.player.play() | |||
_held_buffer = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
music_delivery
will start firing as soon as player.play()
is called and so a race sounds possible. Is there any issue with resetting it along with the other stuff before the try
block?
if _held_buffer: | ||
consumed = audio_actor.emit_data(_held_buffer).get() | ||
else: | ||
consumed = True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does seem weird to attach what we know is the wrong timestamp to the buffer we are emitting to GST (although the error will be very small). Should we be doing something more like buffer_timestamp.increase(_held_buffer.duration)
?
EDIT: Sorry, confused myself. The timestamp attached to the buffer is obviously correct.
I understand and I do agree that delaying the buffer is nicer in theory, but involving inter-thread communication makes it more complicated too. |
And I will add, I love that you've fixed this. There're some albums I've only ever heard on Spotify and they sound almost brand new (better!) with gapless fixed - amazing! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this looks good! All the testing and eyes on what is quite small changes make me confident this is a clear improvement.
I prefer holding the buffer to discarding deliveries of exactly 22050 frames. 22050*2=44100, as in https://en.wikipedia.org/wiki/44,100_Hz, so that sounds like exactly 0.5s of audio and not an obscure sample size we'll never see in other situations.
Regarding the failing CI checks:
|
What happens with the global |
EDIT: pytest doesn't run tests in parallel, that was nose that did that. |
Closing this in favour of the version at #277 that's tweaked for easier testing. |
After the song ends, libspotify continues to push data to the buffer, which breaks gapless playback.
This change fixes this by unloading the track after it ends.
Closes #160.