-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Add support to send voice messages #10230
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
base: master
Are you sure you want to change the base?
Changes from 24 commits
4480fea
31344ca
aa5c26f
a7270b2
4a40683
9a4617e
f9ca81a
eb62338
27bca43
d1747b9
2a96d13
8332ca3
5231d51
2e6bfd3
60030d8
1d2ab9c
50cb4f6
3dd7f8f
e5cca7d
8f1d548
9936b0d
8bc906e
dd2fd33
bb4de89
0f3bc42
8bea5c3
394b16e
0f1ded6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,6 +27,10 @@ | |
|
||
import os | ||
import io | ||
import base64 | ||
from .oggparse import OggStream | ||
from .opus import Decoder | ||
import struct | ||
|
||
from .utils import MISSING | ||
|
||
|
@@ -75,9 +79,37 @@ class File: | |
The file description to display, currently only supported for images. | ||
.. versionadded:: 2.0 | ||
voice: :class:`bool` | ||
Whether the file is a voice message. If left unspecified, the :attr:`~File.duration` is used | ||
to determine if the file is a voice message. | ||
.. note:: | ||
Voice files must be an audio only format. | ||
A *non-exhaustive* list of supported formats are: `ogg`, `mp3`, `wav`, `aac`, and `flac`. | ||
.. versionadded:: 2.6 | ||
duration: Optional[:class:`float`] | ||
The duration of the voice message in seconds | ||
.. versionadded:: 2.6 | ||
""" | ||
|
||
__slots__ = ('fp', '_filename', 'spoiler', 'description', '_original_pos', '_owner', '_closer') | ||
__slots__ = ( | ||
'fp', | ||
'_filename', | ||
'spoiler', | ||
'description', | ||
'_original_pos', | ||
'_owner', | ||
'_closer', | ||
'duration', | ||
'_waveform', | ||
'voice', | ||
) | ||
|
||
def __init__( | ||
self, | ||
|
@@ -86,6 +118,9 @@ def __init__( | |
*, | ||
spoiler: bool = MISSING, | ||
description: Optional[str] = None, | ||
voice: bool = MISSING, | ||
duration: Optional[float] = None, | ||
waveform: Optional[str] = None, | ||
): | ||
if isinstance(fp, io.IOBase): | ||
if not (fp.seekable() and fp.readable()): | ||
|
@@ -117,6 +152,15 @@ def __init__( | |
|
||
self.spoiler: bool = spoiler | ||
self.description: Optional[str] = description | ||
self.duration = duration | ||
self._waveform = waveform | ||
|
||
if voice is MISSING: | ||
voice = duration is not None | ||
self.voice = voice | ||
|
||
if duration is None and voice: | ||
raise TypeError('Voice messages must have a duration') | ||
|
||
@property | ||
def filename(self) -> str: | ||
|
@@ -126,6 +170,24 @@ def filename(self) -> str: | |
""" | ||
return 'SPOILER_' + self._filename if self.spoiler else self._filename | ||
|
||
@property | ||
def waveform(self) -> str: | ||
""":class:`str`: The waveform data for the voice message. | ||
.. note:: | ||
If a waveform was not given, it will be generated | ||
Only supports generating the waveform for Opus format files, other files will be given a random waveform | ||
.. versionadded:: 2.6""" | ||
if self._waveform is None: | ||
try: | ||
self._waveform = self.generate_waveform() | ||
except Exception: | ||
self._waveform = base64.b64encode(os.urandom(256)).decode('utf-8') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if I'm a big fan of this caveat, especially not as a catch-all except case. I think us handling audio extraction for anything outside of opus 'in house' is pretty far out of scope, but this feels like a design 'lock-in' that could prevent people who have the means to do the waveform generation themselves from doing so. Maybe the waveform property could have a setter, or maybe there could be a way to construct a File (or a specialized subclass) with a waveform provided e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can actually pass in your own waveform when creating a file There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, that makes this less of an issue, but I still think we should avoid generating a fake waveform if we can, maybe we could just let the exception be raised instead or split generation and non-generation into classmethod-based signatures like suggested. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Problem is that if someone passes in an mp3 file, we still need to generate a waveform. I don't see the point in adding extra steps for the user to generate a waveform dependant on if they have the correct audio type as that could be confusing |
||
self.reset() | ||
return self._waveform | ||
|
||
@filename.setter | ||
def filename(self, value: str) -> None: | ||
self._filename, self.spoiler = _strip_spoiler(value) | ||
|
@@ -156,4 +218,63 @@ def to_dict(self, index: int) -> Dict[str, Any]: | |
if self.description is not None: | ||
payload['description'] = self.description | ||
|
||
if self.voice: | ||
payload['duration_secs'] = self.duration | ||
payload['waveform'] = self.waveform | ||
|
||
return payload | ||
|
||
def generate_waveform(self) -> str: | ||
if not self.voice: | ||
raise TypeError("Cannot produce waveform for non voice file") | ||
blord0 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.reset() | ||
ogg = OggStream(self.fp) # type: ignore | ||
blord0 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
decoder = Decoder() | ||
waveform: list[int] = [] | ||
prefixes = [b'OpusHead', b'OpusTags'] | ||
for packet in ogg.iter_packets(): | ||
if packet[:8] in prefixes: | ||
continue | ||
blord0 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if b'vorbis' in packet: | ||
raise TypeError("File format is 'vorbis'. Format of 'opus' is required for waveform generation") | ||
blord0 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# these are PCM bytes in 16-bit signed little-endian form | ||
decoded = decoder.decode(packet, fec=False) | ||
|
||
# 16 bits -> 2 bytes per sample | ||
num_samples = len(decoded) // 2 | ||
|
||
# https://docs.python.org/3/library/struct.html#byte-order-size-and-alignment | ||
format = '<' + 'h' * num_samples | ||
samples: tuple[int] = struct.unpack(format, decoded) | ||
|
||
waveform.extend(samples) | ||
|
||
# Make sure all values are positive | ||
for i in range(len(waveform)): | ||
if waveform[i] < 0: | ||
waveform[i] = -waveform[i] | ||
|
||
point_count: int = self.duration * 10 # type: ignore | ||
point_count = min(point_count, 255) | ||
points_per_sample: int = len(waveform) // point_count | ||
sample_waveform: list[int] = [] | ||
|
||
total, count = 0, 0 | ||
# Average out the amplitudes for each point within a sample | ||
for i in range(len(waveform)): | ||
total += waveform[i] | ||
count += 1 | ||
if i % points_per_sample == 0: | ||
sample_waveform.append(total // count) | ||
total, count = 0, 0 | ||
|
||
# Maximum value of a waveform is 0xff (255) | ||
highest = max(sample_waveform) | ||
mult = 255 / highest | ||
for i in range(len(sample_waveform)): | ||
sample_waveform[i] = int(sample_waveform[i] * mult) | ||
Comment on lines
+266
to
+284
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already rely on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. audioop is depricated in python 3.11 and removed since 3.13 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already include There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The part of code you have commented this on doesn't decode or process the Opus data. |
||
|
||
print(len(sample_waveform)) | ||
return base64.b64encode(bytes(sample_waveform)).decode('utf-8') |
Uh oh!
There was an error while loading. Please reload this page.