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

Audio queue manager, solution for #292 #462

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
:orphan:


2.10.1
=======
- ext.sounds
- Additions
- Added :class:`Twitchio.ext.sounds.AudioQueueManager` to manage a queue of audio files to be played sequentially with optional repeat functionality.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.add_audio` to add a new audio file to the queue.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.play_next` to play the next audio file in the queue.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.skip_audio` to stop the currently playing audio file.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.stop_audio` to stop the currently playing audio file and reset the playing flag.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.pause_audio` to pause the currently playing audio file.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.resume_audio` to resume the currently paused audio file.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.clear_queue` to clear all audio files from the queue.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.pause_queue` to pause the processing of the queue.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.resume_queue` to resume the processing of the queue.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.get_queue_contents` to retrieve the current contents of the queue.
- Added :method:`Twitchio.ext.sounds.AudioQueueManager.queue_loop` to continuously check the queue and play the next audio file if not currently playing and not paused.


2.10.0
=======
- TwitchIO
Expand All @@ -17,6 +35,12 @@
:method:`Twitchio.ext.eventsub.EventSubWSClient.subscribe_channel_unban_request_create <EventSubWSClient.subscribe_channel_unban_request_create>`
- Added :method:`Twitchio.ext.eventsub.EventSubClient.subscribe_channel_unban_request_resolve <EventSubClient.subscribe_channel_unban_request_resolve>` /
:method:`Twitchio.ext.eventsub.EventSubWSClient.subscribe_channel_unban_request_resolve <EventSubWSClient.subscribe_channel_unban_request_resolve>`
- ext.sounds
- Additions
- Added TinyTag as a dependency to support retrieving audio metadata using TinyTag in `ext.sounds.__init__.py`.
- added :method:`Twitchio.ext.sounds.rate setter.
- added :method:`Twitchio.ext.sounds.channels setter.


2.9.2
=======
Expand Down Expand Up @@ -143,6 +167,7 @@
- Bumped ciso8601 from >=2.2,<2.3 to >=2.2,<3
- Bumped cchardet from >=2.1,<2.2 to >=2.1,<3


2.6.0
======
- TwitchIO
Expand Down
6 changes: 5 additions & 1 deletion docs/exts/sounds.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,15 @@ This bot will search YouTube for a relevant video and playback its audio.

**Sound with a Local File:**

This Sound will target a local file on your machine. Just pass the location to source.
This Sound will target a local file on your machine. Pass the location to source. You
may manually set the sample rate and number of channels if needed, however it should
be automatically detected.

.. code-block:: python3

sound = sounds.Sound(source='my_audio.mp3')
sound.channels = 1 # play mono channel
sound.rate = 24_000 # set sample


**Multiple Players:**
Expand Down
3 changes: 2 additions & 1 deletion docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ sphinxext-opengraph
Pygments
furo
pyaudio==0.2.11
yt-dlp>=2022.2.4
yt-dlp>=2022.2.4
tinytag>=1.9.0
57 changes: 57 additions & 0 deletions examples/music_queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import asyncio
from twitchio.ext import commands, sounds
from twitchio.ext.sounds import queuemanager


class Bot(commands.Bot):

def __init__(self):
super().__init__(token="TOKEN", prefix="!", initial_channels=["CHANNEL"])
self.audio_manager = queuemanager.AudioQueueManager()

# Adding sound files paths to the queue for uses to choose from
song_dict = {
"song_one": "\\PATH\\TO\\FILE.mp3",
"song_two": "\\PATH\\TO\\FILE.mp3",
"song_three": "\\PATH\\TO\\FILE.mp3",
}

async def event_ready(self):
loop = asyncio.get_event_loop()

# Start the queue loop
self.task = loop.create_task(self.audio_manager.queue_loop())

@commands.command(name="sr")
async def addsound(self, ctx: commands.Context, sound: str):
sound_path = self.song_dict[sound]
await self.audio_manager.add_audio(sound_path)
await ctx.send(f"Added sound to queue: {sound_path}")

@commands.command(name="skip")
async def skip(self, ctx: commands.Context):
await ctx.send(f"Skipped the current sound. {self.audio_manager.current_sound}")
await self.audio_manager.skip_audio()

@commands.command(name="pause")
async def pause(self, ctx: commands.Context):
await self.audio_manager.pause_audio()

@commands.command(name="resume")
async def resume(self, ctx: commands.Context):
await self.audio_manager.resume_audio()

@commands.command(name="queue")
async def queue(self, ctx: commands.Context):
queue_contents = await self.audio_manager.get_queue_contents()
await ctx.send(f"Queue contents: {queue_contents}")

# Override close method to gracefully cancel the task
async def close(self):
self.task.cancel()
await super().close()


if __name__ == "__main__":
bot = Bot()
bot.run()
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
sounds = [
"yt-dlp>=2022.2.4",
'pyaudio==0.2.11; platform_system!="Windows"',
'tinytag>=1.9.0',
]
speed = [
"ujson>=5.2,<6",
Expand Down
18 changes: 15 additions & 3 deletions twitchio/ext/sounds/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

import asyncio
import audioop
import dataclasses
Expand All @@ -33,6 +34,7 @@

import pyaudio
from yt_dlp import YoutubeDL
from tinytag import TinyTag


__all__ = ("Sound", "AudioPlayer")
Expand Down Expand Up @@ -173,6 +175,9 @@ def __init__(

elif isinstance(source, str):
self.title = source
tag = TinyTag.get(source)
self._rate = tag.samplerate
self._channels = tag.channels

self.proc = subprocess.Popen(
[
Expand All @@ -189,9 +194,6 @@ def __init__(
stdout=subprocess.PIPE,
)

self._channels = 2
self._rate = 48000

@classmethod
async def ytdl_search(cls, search: str, *, loop: Optional[asyncio.BaseEventLoop] = None):
"""|coro|
Expand All @@ -216,11 +218,21 @@ def channels(self):
"""The audio source channels."""
return self._channels

@channels.setter
def channels(self, channels: int):
"""Set audio source channels."""
self._channels = channels

@property
def rate(self):
"""The audio source sample rate."""
return self._rate

@rate.setter
def rate(self, rate: int):
"""Set audio source sample rate."""
self._rate = rate

@property
def source(self):
"""The raw audio source."""
Expand Down
141 changes: 141 additions & 0 deletions twitchio/ext/sounds/queuemanager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import asyncio
from twitchio.ext import sounds


class AudioQueueManager:
"""
Manages a queue of audio files to be played sequentially with optional repeat and pause functionalities.

Attributes:
queue (asyncio.Queue[str]): A queue to hold paths of audio files to be played.
is_playing (bool): Indicates whether an audio file is currently being played.
repeat_queue (bool): If True, adds the current playing audio file back to the queue after playing.
queue_paused (bool): If True, pauses the processing of the queue.
player (sounds.AudioPlayer): An instance of AudioPlayer to play audio files.
current_sound (str): Path of the currently playing audio file.
"""

def __init__(self):
"""
Initializes an instance of AudioQueueManager with an empty queue and default settings.
"""
self.queue: asyncio.Queue[str] = asyncio.Queue()
self.is_playing: bool = False
self.repeat_queue: bool = True
self.queue_paused: bool = False
self.player: sounds.AudioPlayer = sounds.AudioPlayer(
callback=self.player_done)
self.current_sound: str = ""

async def player_done(self) -> None:
"""
Callback method called when the player finishes playing an audio file.
Resets the is_playing flag and marks the current task as done in the queue.
"""
await asyncio.sleep(0.1)
self.is_playing = False
self.queue.task_done()

async def add_audio(self, sound_path: str) -> None:
"""
Adds a new audio file to the queue.

Args:
sound_path (str): Path of the audio file to add to the queue.
"""
await asyncio.sleep(0.1)
await self.queue.put(sound_path)

async def play_next(self) -> None:
"""
Plays the next audio file in the queue if the queue is not empty and not paused.
Sets the is_playing flag, retrieves the next audio file from the queue, and plays it.
If repeat_queue is True, adds the current audio file back to the queue after playing.
"""
await asyncio.sleep(0.1)
if not self.queue.empty() and not self.queue_paused:
self.is_playing = True
sound_path = await self.queue.get()
self.current_sound = sound_path
sound = sounds.Sound(source=sound_path)
self.player.play(sound)
if self.repeat_queue:
await self.queue.put(self.current_sound)

async def skip_audio(self) -> None:
"""
Stops the currently playing audio file if there is one.
"""
await asyncio.sleep(0.1)
if self.is_playing:
self.player.stop()
self.is_playing = False

async def stop_audio(self) -> None:
"""
Stops the currently playing audio file.
Resets the playing flag but leaves the queue intact.
"""
await asyncio.sleep(0.1)
if self.is_playing:
self.player.stop()
self.is_playing = False

async def pause_audio(self) -> None:
"""
Pauses the currently playing audio file.
"""
await asyncio.sleep(0.1)
self.player.pause()

async def resume_audio(self) -> None:
"""
Resumes the currently paused audio file.
"""
await asyncio.sleep(0.1)
self.player.resume()

async def clear_queue(self) -> None:
"""
Clears all audio files from the queue.
"""
await asyncio.sleep(0.1)
while not self.queue.empty():
await self.queue.get()
self.queue.task_done()

async def pause_queue(self) -> None:
"""
Pauses the processing of the queue.
"""
await asyncio.sleep(0.1)
self.queue_paused = True

async def resume_queue(self) -> None:
"""
Resumes the processing of the queue.
"""
await asyncio.sleep(0.1)
self.queue_paused = False

async def get_queue_contents(self) -> list:
"""
Retrieves the current contents of the queue.

Returns:
list: List of paths of audio files in the queue.
"""
await asyncio.sleep(0.1)
return list(self.queue._queue)

async def queue_loop(self) -> None:
"""
Continuously checks the queue and plays the next audio file if not currently playing and not paused.
"""
try:
while True:
await asyncio.sleep(0.2)
if not self.is_playing and not self.queue.empty() and not self.queue_paused:
await self.play_next()
finally:
return