From d62182a78231913cd1139def84e89b809e567653 Mon Sep 17 00:00:00 2001 From: Vincent Date: Sat, 22 Jan 2022 18:17:15 +0800 Subject: [PATCH 1/3] Major Refactor of Voice Recording --- discord/__init__.py | 3 +- discord/errors.py | 14 ---- discord/opus.py | 2 +- discord/sinks/__init__.py | 19 +++++ discord/{sink.py => sinks/core.py} | 118 ++++++++--------------------- discord/sinks/errors.py | 74 ++++++++++++++++++ discord/sinks/m4a.py | 96 +++++++++++++++++++++++ discord/sinks/mka.py | 96 +++++++++++++++++++++++ discord/sinks/mkv.py | 96 +++++++++++++++++++++++ discord/sinks/mp3.py | 96 +++++++++++++++++++++++ discord/sinks/mp4.py | 96 +++++++++++++++++++++++ discord/sinks/ogg.py | 96 +++++++++++++++++++++++ discord/sinks/pcm.py | 58 ++++++++++++++ discord/sinks/wave.py | 80 +++++++++++++++++++ discord/voice_client.py | 8 +- 15 files changed, 843 insertions(+), 109 deletions(-) create mode 100644 discord/sinks/__init__.py rename discord/{sink.py => sinks/core.py} (65%) create mode 100644 discord/sinks/errors.py create mode 100644 discord/sinks/m4a.py create mode 100644 discord/sinks/mka.py create mode 100644 discord/sinks/mkv.py create mode 100644 discord/sinks/mp3.py create mode 100644 discord/sinks/mp4.py create mode 100644 discord/sinks/ogg.py create mode 100644 discord/sinks/pcm.py create mode 100644 discord/sinks/wave.py diff --git a/discord/__init__.py b/discord/__init__.py index 7d8e36c481..f4543b8ce8 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -43,7 +43,7 @@ from .widget import * from .object import * from .reaction import * -from . import utils, opus, abc, ui +from . import utils, opus, abc, ui, sinks from .enums import * from .embeds import * from .mentions import * @@ -57,7 +57,6 @@ from .sticker import * from .stage_instance import * from .interactions import * -from .sink import * from .components import * from .threads import * from .bot import * diff --git a/discord/errors.py b/discord/errors.py index 7757137dff..10e26acfd6 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -270,20 +270,6 @@ def __init__(self, shard_id: Optional[int]): ) super().__init__(msg % shard_id) -class RecordingException(ClientException): - """Exception that's thrown when there is an error while trying to record - audio from a voice channel. - - .. versionadded:: 2.0 - """ - pass - -class SinkException(ClientException): - """Raised when a Sink error occurs. - - .. versionadded:: 2.0 - """ - class InteractionResponded(ClientException): """Exception that's raised when sending another interaction response using :class:`InteractionResponse` when one has already been done before. diff --git a/discord/opus.py b/discord/opus.py index a37c5c52db..04470786d5 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -52,7 +52,7 @@ import time from .errors import DiscordException, InvalidArgument -from .sink import RawData +from .sinks import RawData if TYPE_CHECKING: T = TypeVar("T") diff --git a/discord/sinks/__init__.py b/discord/sinks/__init__.py new file mode 100644 index 0000000000..d5dab5f47d --- /dev/null +++ b/discord/sinks/__init__.py @@ -0,0 +1,19 @@ +""" +discord.sinks +~~~~~~~~~~~~~ + +A place to store all officially given voice sinks. + +:copyright: 2021-present Pycord Development +:license: MIT, see LICENSE for more details. +""" +from .core import * +from .errors import * +from .m4a import * +from .mka import * +from .mkv import * +from .mp3 import * +from .mp4 import * +from .ogg import * +from .pcm import * +from .wave import * \ No newline at end of file diff --git a/discord/sink.py b/discord/sinks/core.py similarity index 65% rename from discord/sink.py rename to discord/sinks/core.py index 168115e240..b835f71b52 100644 --- a/discord/sink.py +++ b/discord/sinks/core.py @@ -1,7 +1,8 @@ """ The MIT License (MIT) -Copyright (c) 2015-2021 Rapptz & (c) 2021-present Pycord-Development +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), @@ -21,17 +22,13 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import wave -import logging import os +import struct +import sys import threading import time -import subprocess -import sys -import struct -from .errors import SinkException -_log = logging.getLogger(__name__) +from .errors import SinkException __all__ = ( "Filters", @@ -57,13 +54,14 @@ class Filters: """Filters for sink - .. versionadded:: 2.0 + .. versionadded:: 2.1 Parameters ---------- - interface: :meth:`Filters.interface` - + container + Container of all Filters. """ + def __init__(self, **kwargs): self.filtered_users = kwargs.get("users", default_filters["users"]) self.seconds = kwargs.get("time", default_filters["time"]) @@ -71,7 +69,7 @@ def __init__(self, **kwargs): self.finished = False @staticmethod - def interface(func): # Contains all filters + def container(func): # Contains all filters def _filter(self, data, user): if not self.filtered_users or user in self.filtered_users: return func(self, data, user) @@ -92,9 +90,8 @@ def wait_and_stop(self): class RawData: """Handles raw data from Discord so that it can be decrypted and decoded to be used. - - .. versionadded:: 2.0 + .. versionadded:: 2.1 """ def __init__(self, data, client): @@ -116,9 +113,7 @@ def __init__(self, data, client): class AudioData: """Handles data that's been completely decrypted and decoded and is ready to be saved to file. - - .. versionadded:: 2.0 - + .. versionadded:: 2.1 Raises ------ ClientException @@ -158,15 +153,26 @@ def on_format(self, encoding): class Sink(Filters): """A Sink "stores" all the audio data. - .. versionadded:: 2.0 + Can be subclassed for extra customizablilty, + + .. warning:: + It is although recommended you use, + the officially provided sink classes + like :class:`~discord.sinks.WaveSink` + + just replace the following like so: :: + vc.start_recording( + MySubClassedSink(), + finished_callback, + ctx.channel, + ) + .. versionadded:: 2.1 Parameters ---------- - encoding: :class:`string` - The encoding to use. Valid types include wav, mp3, and pcm (even though it's not an actual encoding). output_path: :class:`string` A path to where the audio files should be output. - + Raises ------ ClientException @@ -174,24 +180,11 @@ class Sink(Filters): Audio may only be formatted after recording is finished. """ - valid_encodings = [ - "wav", - "mp3", - "pcm", - ] - - def __init__(self, *, encoding="wav", output_path="", filters=None): + def __init__(self, *, output_path="", filters=None): if filters is None: filters = default_filters self.filters = filters Filters.__init__(self, **self.filters) - - encoding = encoding.lower() - - if encoding not in self.valid_encodings: - raise SinkException("An invalid encoding type was specified.") - - self.encoding = encoding self.file_path = output_path self.vc = None self.audio_data = {} @@ -200,7 +193,7 @@ def init(self, vc): # called under listen self.vc = vc super().init() - @Filters.interface + @Filters.container def write(self, data, user): if user not in self.audio_data: ssrc = self.vc.get_ssrc(user) @@ -214,55 +207,4 @@ def cleanup(self): self.finished = True for file in self.audio_data.values(): file.cleanup() - self.format_audio(file) - - def format_audio(self, audio): - if self.vc.recording: - raise SinkException( - "Audio may only be formatted after recording is finished." - ) - if self.encoding == "pcm": - return - if self.encoding == "mp3": - mp3_file = audio.file.split(".")[0] + ".mp3" - args = [ - "ffmpeg", - "-f", - "s16le", - "-ar", - "48000", - "-ac", - "2", - "-i", - audio.file, - mp3_file, - ] - process = None - if os.path.exists(mp3_file): - os.remove( - mp3_file - ) # process will get stuck asking whether or not to overwrite, if file already exists. - try: - process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) - except FileNotFoundError: - raise SinkException("ffmpeg was not found.") from None - except subprocess.SubprocessError as exc: - raise SinkException( - "Popen failed: {0.__class__.__name__}: {0}".format(exc) - ) from exc - process.wait() - elif self.encoding == "wav": - with open(audio.file, "rb") as pcm: - data = pcm.read() - pcm.close() - - wav_file = audio.file.split(".")[0] + ".wav" - with wave.open(wav_file, "wb") as f: - f.setnchannels(self.vc.decoder.CHANNELS) - f.setsampwidth(self.vc.decoder.SAMPLE_SIZE // self.vc.decoder.CHANNELS) - f.setframerate(self.vc.decoder.SAMPLING_RATE) - f.writeframes(data) - f.close() - - os.remove(audio.file) - audio.on_format(self.encoding) + self.format_audio(file) \ No newline at end of file diff --git a/discord/sinks/errors.py b/discord/sinks/errors.py new file mode 100644 index 0000000000..e5a20af11c --- /dev/null +++ b/discord/sinks/errors.py @@ -0,0 +1,74 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from discord.errors import DiscordException + + +class SinkException(DiscordException): + """Raised when a Sink error occurs. + .. versionadded:: 2.1 + """ + + +class RecordingException(SinkException): + """Exception that's thrown when there is an error while trying to record + audio from a voice channel. + .. versionadded:: 2.1 + """ + + pass + + +class MP3SinkError(SinkException): + """Exception thrown when a exception occurs with :class:`MP3Sink` + .. versionadded:: 2.1 + """ + + +class MP4SinkError(SinkException): + """Exception thrown when a exception occurs with :class:`MP4Sink` + .. versionadded:: 2.1 + """ + + +class OGGSinkError(SinkException): + """Exception thrown when a exception occurs with :class:`OGGSink` + .. versionadded:: 2.1 + """ + + +class MKVSinkError(SinkException): + """Exception thrown when a exception occurs with :class:`MKVSink` + .. versionadded:: 2.1 + """ + + +class WaveSinkError(SinkException): + """Exception thrown when a exception occurs with :class:`WaveSink` + .. versionadded:: 2.1 + """ + +class M4ASinkError(SinkException): + """Exception thrown when a exception occurs with :class:`M4ASink` + .. versionadded:: 2.1 + """ \ No newline at end of file diff --git a/discord/sinks/m4a.py b/discord/sinks/m4a.py new file mode 100644 index 0000000000..5c6d457702 --- /dev/null +++ b/discord/sinks/m4a.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import subprocess + +from .core import CREATE_NO_WINDOW, Filters, Sink, default_filters +from .errors import M4ASinkError + + +class M4ASink(Sink): + """A Sink "stores" all the audio data. + + Used for .m4a files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "m4a" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise M4ASinkError( + "Audio may only be formatted after recording is finished." + ) + m4a_file = audio.file.split(".")[0] + ".m4a" + args = [ + "ffmpeg", + "-f", + "s16le", + "-ar", + "48000", + "-ac", + "2", + "-i", + audio.file, + m4a_file, + ] + process = None + if os.path.exists(m4a_file): + os.remove( + m4a_file + ) # process will get stuck asking whether or not to overwrite, if file already exists. + try: + process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + except FileNotFoundError: + raise M4ASinkError("ffmpeg was not found.") from None + except subprocess.SubprocessError as exc: + raise M4ASinkError( + "Popen failed: {0.__class__.__name__}: {0}".format(exc) + ) from exc + + process.wait() + + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/sinks/mka.py b/discord/sinks/mka.py new file mode 100644 index 0000000000..b933ef02b7 --- /dev/null +++ b/discord/sinks/mka.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import subprocess + +from .core import CREATE_NO_WINDOW, Filters, Sink, default_filters +from .errors import MKASinkError + + +class MKASink(Sink): + """A Sink "stores" all the audio data. + + Used for .mka files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "mka" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise MKASinkError( + "Audio may only be formatted after recording is finished." + ) + mka_file = audio.file.split(".")[0] + ".mka" + args = [ + "ffmpeg", + "-f", + "s16le", + "-ar", + "48000", + "-ac", + "2", + "-i", + audio.file, + mka_file, + ] + process = None + if os.path.exists(mka_file): + os.remove( + mka_file + ) # process will get stuck asking whether or not to overwrite, if file already exists. + try: + process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + except FileNotFoundError: + raise MKASinkError("ffmpeg was not found.") from None + except subprocess.SubprocessError as exc: + raise MKASinkError( + "Popen failed: {0.__class__.__name__}: {0}".format(exc) + ) from exc + + process.wait() + + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/sinks/mkv.py b/discord/sinks/mkv.py new file mode 100644 index 0000000000..a7398741d7 --- /dev/null +++ b/discord/sinks/mkv.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import subprocess + +from .core import CREATE_NO_WINDOW, Filters, Sink, default_filters +from .errors import MKVSinkError + + +class MKVSink(Sink): + """A Sink "stores" all the audio data. + + Used for .mkv files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "mkv" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise MKVSinkError( + "Audio may only be formatted after recording is finished." + ) + mkv_file = audio.file.split(".")[0] + ".mkv" + args = [ + "ffmpeg", + "-f", + "s16le", + "-ar", + "48000", + "-ac", + "2", + "-i", + audio.file, + mkv_file, + ] + process = None + if os.path.exists(mkv_file): + os.remove( + mkv_file + ) # process will get stuck asking whether or not to overwrite, if file already exists. + try: + process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + except FileNotFoundError: + raise MKVSinkError("ffmpeg was not found.") from None + except subprocess.SubprocessError as exc: + raise MKVSinkError( + "Popen failed: {0.__class__.__name__}: {0}".format(exc) + ) from exc + + process.wait() + + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/sinks/mp3.py b/discord/sinks/mp3.py new file mode 100644 index 0000000000..7700cce254 --- /dev/null +++ b/discord/sinks/mp3.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import subprocess + +from .core import CREATE_NO_WINDOW, Filters, Sink, default_filters +from .errors import MP3SinkError + + +class MP3Sink(Sink): + """A Sink "stores" all the audio data. + + Used for .mp3 files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "mp3" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise MP3SinkError( + "Audio may only be formatted after recording is finished." + ) + mp3_file = audio.file.split(".")[0] + ".mp3" + args = [ + "ffmpeg", + "-f", + "s16le", + "-ar", + "48000", + "-ac", + "2", + "-i", + audio.file, + mp3_file, + ] + process = None + if os.path.exists(mp3_file): + os.remove( + mp3_file + ) # process will get stuck asking whether or not to overwrite, if file already exists. + try: + process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + except FileNotFoundError: + raise MP3SinkError("ffmpeg was not found.") from None + except subprocess.SubprocessError as exc: + raise MP3SinkError( + "Popen failed: {0.__class__.__name__}: {0}".format(exc) + ) from exc + + process.wait() + + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/sinks/mp4.py b/discord/sinks/mp4.py new file mode 100644 index 0000000000..64783f21ae --- /dev/null +++ b/discord/sinks/mp4.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import subprocess + +from .core import CREATE_NO_WINDOW, Filters, Sink, default_filters +from .errors import MP4SinkError + + +class MP4Sink(Sink): + """A Sink "stores" all the audio data. + + Used for .mp4 files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "mp4" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise MP4SinkError( + "Audio may only be formatted after recording is finished." + ) + mp4_file = audio.file.split(".")[0] + ".mp4" + args = [ + "ffmpeg", + "-f", + "s16le", + "-ar", + "48000", + "-ac", + "2", + "-i", + audio.file, + mp4_file, + ] + process = None + if os.path.exists(mp4_file): + os.remove( + mp4_file + ) # process will get stuck asking whether or not to overwrite, if file already exists. + try: + process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + except FileNotFoundError: + raise MP4SinkError("ffmpeg was not found.") from None + except subprocess.SubprocessError as exc: + raise MP4SinkError( + "Popen failed: {0.__class__.__name__}: {0}".format(exc) + ) from exc + + process.wait() + + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/sinks/ogg.py b/discord/sinks/ogg.py new file mode 100644 index 0000000000..fe410ce4a8 --- /dev/null +++ b/discord/sinks/ogg.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import subprocess + +from .core import CREATE_NO_WINDOW, Filters, Sink, default_filters +from .errors import OGGSinkError + + +class OGGSink(Sink): + """A Sink "stores" all the audio data. + + Used for .ogg files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "ogg" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise OGGSinkError( + "Audio may only be formatted after recording is finished." + ) + ogg_file = audio.file.split(".")[0] + ".ogg" + args = [ + "ffmpeg", + "-f", + "s16le", + "-ar", + "48000", + "-ac", + "2", + "-i", + audio.file, + ogg_file, + ] + process = None + if os.path.exists(ogg_file): + os.remove( + ogg_file + ) # process will get stuck asking whether or not to overwrite, if file already exists. + try: + process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW) + except FileNotFoundError: + raise OGGSinkError("ffmpeg was not found.") from None + except subprocess.SubprocessError as exc: + raise OGGSinkError( + "Popen failed: {0.__class__.__name__}: {0}".format(exc) + ) from exc + + process.wait() + + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/sinks/pcm.py b/discord/sinks/pcm.py new file mode 100644 index 0000000000..c547f0d6ba --- /dev/null +++ b/discord/sinks/pcm.py @@ -0,0 +1,58 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from .core import Filters, Sink, default_filters + + +class PCMSink(Sink): + """A Sink "stores" all the audio data. + + Used for .pcm files. + + .. versionadded:: 2.1 + + Parameters + ---------- + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "ogg" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + return \ No newline at end of file diff --git a/discord/sinks/wave.py b/discord/sinks/wave.py new file mode 100644 index 0000000000..fe4b716442 --- /dev/null +++ b/discord/sinks/wave.py @@ -0,0 +1,80 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +import os +import wave + +from .core import Filters, Sink, default_filters +from .errors import WaveSinkError + + +class WaveSink(Sink): + """A Sink "stores" all the audio data. + + Used for .wav(wave) files. + + .. versionadded:: 2.1 + + Parameters + ---------- + encoding: :class:`string` + The encoding to use. Valid types include wav, mp3, and pcm (even though it's not an actual encoding). + output_path: :class:`string` + A path to where the audio files should be output. + + Raises + ------ + ClientException + An invalid encoding type was specified. + Audio may only be formatted after recording is finished. + """ + + def __init__(self, *, output_path="", filters=None): + if filters is None: + filters = default_filters + self.filters = filters + Filters.__init__(self, **self.filters) + + self.encoding = "wav" + self.file_path = output_path + self.vc = None + self.audio_data = {} + + def format_audio(self, audio): + if self.vc.recording: + raise WaveSinkError( + "Audio may only be formatted after recording is finished." + ) + with open(audio.file, "rb") as pcm: + data = pcm.read() + pcm.close() + + wav_file = audio.file.split(".")[0] + ".wav" + with wave.open(wav_file, "wb") as f: + f.setnchannels(self.vc.decoder.CHANNELS) + f.setsampwidth(self.vc.decoder.SAMPLE_SIZE // self.vc.decoder.CHANNELS) + f.setframerate(self.vc.decoder.SAMPLING_RATE) + f.writeframes(data) + f.close() + os.remove(audio.file) + audio.on_format(self.encoding) \ No newline at end of file diff --git a/discord/voice_client.py b/discord/voice_client.py index 26fc7e8581..27351e7603 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -54,7 +54,7 @@ from .gateway import * from .errors import ClientException, ConnectionClosed, RecordingException from .player import AudioPlayer, AudioSource -from .sink import Sink, RawData +from .sinks import Sink, RawData from .utils import MISSING @@ -795,9 +795,9 @@ def empty_socket(self): s.recv(4096) def recv_audio(self, sink, callback, *args): - # Gets data from _recv_audio and sorts - # it by user, handles pcm files and - # silence that should be added. + # Gets data from _recv_audio and sorts + # it by user, handles pcm files and + # silence that should be added. self.user_timestamps = {} self.starting_time = time.perf_counter() From c8bc1c5f62fc2b403f3897da8665e58f8056d07c Mon Sep 17 00:00:00 2001 From: Vincent Date: Sat, 22 Jan 2022 18:24:14 +0800 Subject: [PATCH 2/3] fix: port example changes --- examples/audio_recording.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/examples/audio_recording.py b/examples/audio_recording.py index e16d018cef..e6391cfc8f 100644 --- a/examples/audio_recording.py +++ b/examples/audio_recording.py @@ -1,13 +1,13 @@ import os import discord -from discord.commands import Option +from discord.commands import Option, ApplicationContext bot = discord.Bot(debug_guilds=[...]) bot.connections = {} @bot.command() -async def start(ctx, encoding: Option(str, choices=["mp3", "wav", "pcm"])): +async def start(ctx: ApplicationContext, encoding: Option(str, choices=["mp3", "wav", "pcm", "ogg", "mka", "mkv", "mp4", "m4a",])): """ Record your voice! """ @@ -20,8 +20,25 @@ async def start(ctx, encoding: Option(str, choices=["mp3", "wav", "pcm"])): vc = await voice.channel.connect() bot.connections.update({ctx.guild.id: vc}) + if encoding == "mp3": + sink = discord.sinks.MP4Sink() + elif encoding == "wav": + sink = discord.sinks.WaveSink() + elif encoding == "pcm": + sink = discord.sinks.PCMSink() + elif encoding == "ogg": + sink = discord.sinks.OGGSink() + elif encoding == "mka": + sink = discord.sinks.MKASink() + elif encoding == "mkv": + sink = discord.sinks.MKVSink() + elif encoding == "mp4": + sink = discord.sinks.MP4Sink() + elif encoding == "m4a": + sink = discord.sinks.M4ASink() + vc.start_recording( - discord.Sink(encoding=encoding), + sink, finished_callback, ctx.channel, ) From e9c10d8c6fd9a83f09ae5265b3809d65e4435fac Mon Sep 17 00:00:00 2001 From: Vincent Date: Sat, 22 Jan 2022 19:13:58 +0800 Subject: [PATCH 3/3] fix: some things --- .gitignore | 2 ++ discord/errors.py | 1 - discord/sinks/core.py | 2 +- discord/sinks/errors.py | 14 ++++++++ discord/voice_client.py | 14 ++++---- docs/api.rst | 71 +++++++++++++++++++++++++++++++++++++---- 6 files changed, 88 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 0c1180d8f4..d596024a24 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ docs/crowdin.py *.mp3 *.m4a *.wav +*.mp4 +*.ogg *.pcm *.png *.jpg diff --git a/discord/errors.py b/discord/errors.py index 10e26acfd6..bf4abbea68 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -60,7 +60,6 @@ 'NoEntryPointError', 'ExtensionFailed', 'ExtensionNotFound', - 'RecordingException', ) diff --git a/discord/sinks/core.py b/discord/sinks/core.py index b835f71b52..7827d44854 100644 --- a/discord/sinks/core.py +++ b/discord/sinks/core.py @@ -85,7 +85,7 @@ def wait_and_stop(self): time.sleep(self.seconds) if self.finished: return - self.vc.stop_listening() + self.vc.stop_recording() class RawData: diff --git a/discord/sinks/errors.py b/discord/sinks/errors.py index e5a20af11c..eb42402d41 100644 --- a/discord/sinks/errors.py +++ b/discord/sinks/errors.py @@ -26,6 +26,7 @@ class SinkException(DiscordException): """Raised when a Sink error occurs. + .. versionadded:: 2.1 """ @@ -33,6 +34,7 @@ class SinkException(DiscordException): class RecordingException(SinkException): """Exception that's thrown when there is an error while trying to record audio from a voice channel. + .. versionadded:: 2.1 """ @@ -41,34 +43,46 @@ class RecordingException(SinkException): class MP3SinkError(SinkException): """Exception thrown when a exception occurs with :class:`MP3Sink` + .. versionadded:: 2.1 """ class MP4SinkError(SinkException): """Exception thrown when a exception occurs with :class:`MP4Sink` + .. versionadded:: 2.1 """ class OGGSinkError(SinkException): """Exception thrown when a exception occurs with :class:`OGGSink` + .. versionadded:: 2.1 """ class MKVSinkError(SinkException): """Exception thrown when a exception occurs with :class:`MKVSink` + .. versionadded:: 2.1 """ class WaveSinkError(SinkException): """Exception thrown when a exception occurs with :class:`WaveSink` + .. versionadded:: 2.1 """ class M4ASinkError(SinkException): """Exception thrown when a exception occurs with :class:`M4ASink` + + .. versionadded:: 2.1 + """ + +class MKASinkError(SinkException): + """Exception thrown when a exception occurs with :class:`MKAsSink` + .. versionadded:: 2.1 """ \ No newline at end of file diff --git a/discord/voice_client.py b/discord/voice_client.py index 27351e7603..9c199c6adf 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -52,9 +52,9 @@ from . import opus, utils from .backoff import ExponentialBackoff from .gateway import * -from .errors import ClientException, ConnectionClosed, RecordingException +from .errors import ClientException, ConnectionClosed from .player import AudioPlayer, AudioSource -from .sinks import Sink, RawData +from .sinks import Sink, RawData, RecordingException from .utils import MISSING @@ -704,13 +704,13 @@ def unpack_audio(self, data): self.decoder.decode(data) - def listen(self, sink, callback, *args): + def start_recording(self, sink, callback, *args): """The bot will begin recording audio from the current voice channel it is in. This function uses a thread so the current code line will not be stopped. Must be in a voice channel to use. Must not be already recording. - .. versionadded:: 2.0 + .. versionadded:: 2.1 Parameters ---------- @@ -754,12 +754,12 @@ def listen(self, sink, callback, *args): ) t.start() - def stop_listening(self): + def stop_recording(self): """Stops the recording. Must be already recording. Raises - .. versionadded:: 2.0 + .. versionadded:: 2.1 ------ RecordingException @@ -811,7 +811,7 @@ def recv_audio(self, sink, callback, *args): try: data = self.socket.recv(4096) except OSError: - self.stop_listening() + self.stop_recording() continue self.unpack_audio(data) diff --git a/docs/api.rst b/docs/api.rst index da3aad3996..1a20983a1d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4592,24 +4592,81 @@ Select .. autofunction:: discord.ui.select -Voice Recording ---------------- +Sink Core +--------- + +.. autoclass:: discord.sinks.Filters + :members: + +.. autoclass:: discord.sinks.Sink + :members: + +.. autoclass:: discord.sinks.AudioData + :members: + +.. autoclass:: discord.sinks.RawData + :members: + + +Sinks +----- + +.. autoclass:: discord.sinks.WaveSink + :members: + +.. autoclass:: discord.sinks.MP3Sink + :members: -.. attributetable:: discord.sink +.. autoclass:: discord.sinks.MP4Sink + :members: -.. autoclass:: discord.sink.Filters +.. autoclass:: discord.sinks.M4ASink :members: -.. autoclass:: discord.sink.Sink +.. autoclass:: discord.sinks.MKVSink :members: -.. autoclass:: discord.sink.AudioData +.. autoclass:: discord.sinks.MKASink :members: -.. autoclass:: discord.sink.RawData +.. autoclass:: discord.sinks.OGGSink :members: +Sink Error Reference +-------------------- + +.. autoexception:: discord.sinks.WaveSinkError + +.. autoexception:: discord.sinks.MP3SinkError + +.. autoexception:: discord.sinks.MP4SinkError + +.. autoexception:: discord.sinks.M4ASinkError + +.. autoexception:: discord.sinks.MKVSinkError + +.. autoexception:: discord.sinks.MKASinkError + +.. autoexception:: discord.sinks.OGGSinkError + +Sink Exception Hierarchy +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. exception_hierarchy:: + + - :exc:`DiscordException` + - :exc:`SinkException` + - :exc:`RecordingException` + - :exc:`WaveSinkError` + - :exc:`MP3SinkError` + - :exc:`MP4SinkError` + - :exc:`M4ASinkError` + - :exc:`MKVSinkError` + - :exc:`MKASinkError` + - :exc:`OGGSinkError` + + Exceptions ------------