Skip to content

Commit 7a6a42c

Browse files
OmLankepre-commit-ci[bot]VincentRPSplun1331Lulalaby
authored
feat: Add synchronization to start of audio recordings (#1984)
Signed-off-by: Om <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: VincentRPS <[email protected]> Co-authored-by: plun1331 <[email protected]> Co-authored-by: Lala Sabathil <[email protected]> Co-authored-by: JustaSqu1d <[email protected]>
1 parent 4675c6c commit 7a6a42c

File tree

4 files changed

+157
-14
lines changed

4 files changed

+157
-14
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ These changes are available on the `master` branch, but have not yet been releas
2929
([#1983](https://github.com/Pycord-Development/pycord/pull/1983))
3030
- Added new `application_auto_moderation_rule_create_badge` to `ApplicationFlags`.
3131
([#1992](https://github.com/Pycord-Development/pycord/pull/1992))
32+
- Added `sync_start` argument to `VoiceClient.start_recording()`. This adds silence to
33+
the start of audio recordings.
34+
([#1984](https://github.com/Pycord-Development/pycord/pull/1984))
3235
- Added `custom_message` to AutoModActionMetadata.
3336
([#2029](https://github.com/Pycord-Development/pycord/pull/2029))
3437
- Added support for

discord/sinks/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def __init__(self, data, client):
115115
self.decoded_data = None
116116

117117
self.user_id = None
118+
self.receive_time = time.perf_counter()
118119

119120

120121
class AudioData:

discord/voice_client.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ def unpack_audio(self, data):
700700

701701
self.decoder.decode(data)
702702

703-
def start_recording(self, sink, callback, *args):
703+
def start_recording(self, sink, callback, *args, sync_start: bool = False):
704704
"""The bot will begin recording audio from the current voice channel it is in.
705705
This function uses a thread so the current code line will not be stopped.
706706
Must be in a voice channel to use.
@@ -716,6 +716,9 @@ def start_recording(self, sink, callback, *args):
716716
A function which is called after the bot has stopped recording.
717717
*args:
718718
Args which will be passed to the callback function.
719+
sync_start: :class:`bool`
720+
If True, the recordings of subsequent users will start with silence.
721+
This is useful for recording audio just as it was heard.
719722
720723
Raises
721724
------
@@ -738,6 +741,7 @@ def start_recording(self, sink, callback, *args):
738741
self.decoder = opus.DecodeManager(self)
739742
self.decoder.start()
740743
self.recording = True
744+
self.sync_start = sync_start
741745
self.sink = sink
742746
sink.init(self)
743747

@@ -796,8 +800,9 @@ def recv_audio(self, sink, callback, *args):
796800
# it by user, handles pcm files and
797801
# silence that should be added.
798802

799-
self.user_timestamps = {}
803+
self.user_timestamps: dict[int, tuple[int, float]] = {}
800804
self.starting_time = time.perf_counter()
805+
self.first_packet_timestamp: float
801806
while self.recording:
802807
ready, _, err = select.select([self.socket], [], [self.socket], 0.01)
803808
if not ready:
@@ -815,27 +820,46 @@ def recv_audio(self, sink, callback, *args):
815820

816821
self.stopping_time = time.perf_counter()
817822
self.sink.cleanup()
818-
callback = asyncio.run_coroutine_threadsafe(
819-
callback(self.sink, *args), self.loop
820-
)
823+
callback = asyncio.run_coroutine_threadsafe(callback(sink, *args), self.loop)
821824
result = callback.result()
822825

823826
if result is not None:
824827
print(result)
825828

826-
def recv_decoded_audio(self, data):
827-
if data.ssrc not in self.user_timestamps:
828-
self.user_timestamps.update({data.ssrc: data.timestamp})
829-
# Add silence when they were not being recorded.
830-
silence = 0
831-
else:
832-
silence = data.timestamp - self.user_timestamps[data.ssrc] - 960
833-
self.user_timestamps[data.ssrc] = data.timestamp
829+
def recv_decoded_audio(self, data: RawData):
830+
# Add silence when they were not being recorded.
831+
if data.ssrc not in self.user_timestamps: # First packet from user
832+
if (
833+
not self.user_timestamps or not self.sync_start
834+
): # First packet from anyone
835+
self.first_packet_timestamp = data.receive_time
836+
silence = 0
837+
838+
else: # Previously received a packet from someone else
839+
silence = (
840+
(data.receive_time - self.first_packet_timestamp) * 48000
841+
) - 960
842+
843+
else: # Previously received a packet from user
844+
dRT = (
845+
data.receive_time - self.user_timestamps[data.ssrc][1]
846+
) * 48000 # delta receive time
847+
dT = data.timestamp - self.user_timestamps[data.ssrc][0] # delta timestamp
848+
diff = abs(100 - dT * 100 / dRT)
849+
if (
850+
diff > 60 and dT != 960
851+
): # If the difference in change is more than 60% threshold
852+
silence = dRT - 960
853+
else:
854+
silence = dT - 960
855+
856+
self.user_timestamps.update({data.ssrc: (data.timestamp, data.receive_time)})
834857

835858
data.decoded_data = (
836-
struct.pack("<h", 0) * silence * opus._OpusStruct.CHANNELS
859+
struct.pack("<h", 0) * max(0, int(silence)) * opus._OpusStruct.CHANNELS
837860
+ data.decoded_data
838861
)
862+
839863
while data.ssrc not in self.ws.ssrc_map:
840864
time.sleep(0.05)
841865
self.sink.write(data.decoded_data, self.ws.ssrc_map[data.ssrc]["user_id"])

examples/audio_recording_merged.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import io
2+
3+
import pydub # pip install pydub==0.25.1
4+
5+
import discord
6+
from discord.sinks import MP3Sink
7+
8+
bot = discord.Bot()
9+
connections: dict[int, discord.VoiceClient] = {}
10+
11+
12+
@bot.event
13+
async def on_ready():
14+
print(f"Logged in as {bot.user}")
15+
16+
17+
async def finished_callback(sink: MP3Sink, channel: discord.TextChannel):
18+
mention_strs = []
19+
audio_segs: list[pydub.AudioSegment] = []
20+
files: list[discord.File] = []
21+
22+
longest = pydub.AudioSegment.empty()
23+
24+
for user_id, audio in sink.audio_data.items():
25+
mention_strs.append(f"<@{user_id}>")
26+
27+
seg = pydub.AudioSegment.from_file(audio.file, format="mp3")
28+
29+
# Determine the longest audio segment
30+
if len(seg) > len(longest):
31+
audio_segs.append(longest)
32+
longest = seg
33+
else:
34+
audio_segs.append(seg)
35+
36+
audio.file.seek(0)
37+
files.append(discord.File(audio.file, filename=f"{user_id}.mp3"))
38+
39+
for seg in audio_segs:
40+
longest = longest.overlay(seg)
41+
42+
with io.BytesIO() as f:
43+
longest.export(f, format="mp3")
44+
await channel.send(
45+
f"Finished! Recorded audio for {', '.join(mention_strs)}.",
46+
files=files + [discord.File(f, filename="recording.mp3")],
47+
)
48+
49+
50+
@bot.command()
51+
async def join(ctx: discord.ApplicationContext):
52+
"""Join the voice channel!"""
53+
voice = ctx.author.voice
54+
55+
if not voice:
56+
return await ctx.respond("You're not in a vc right now")
57+
58+
vc = await voice.channel.connect()
59+
connections.update({ctx.guild.id: vc})
60+
61+
await ctx.respond("Joined!")
62+
63+
64+
@bot.command()
65+
async def start(ctx: discord.ApplicationContext):
66+
"""Record the voice channel!"""
67+
voice = ctx.author.voice
68+
69+
if not voice:
70+
return await ctx.respond("You're not in a vc right now")
71+
72+
vc = connections.get(ctx.guild.id)
73+
74+
if not vc:
75+
return await ctx.respond(
76+
"I'm not in a vc right now. Use `/join` to make me join!"
77+
)
78+
79+
vc.start_recording(
80+
MP3Sink(),
81+
finished_callback,
82+
ctx.channel,
83+
sync_start=True,
84+
)
85+
86+
await ctx.respond("The recording has started!")
87+
88+
89+
@bot.command()
90+
async def stop(ctx: discord.ApplicationContext):
91+
"""Stop the recording"""
92+
vc = connections.get(ctx.guild.id)
93+
94+
if not vc:
95+
return await ctx.respond("There's no recording going on right now")
96+
97+
vc.stop_recording()
98+
99+
await ctx.respond("The recording has stopped!")
100+
101+
102+
@bot.command()
103+
async def leave(ctx: discord.ApplicationContext):
104+
"""Leave the voice channel!"""
105+
vc = connections.get(ctx.guild.id)
106+
107+
if not vc:
108+
return await ctx.respond("I'm not in a vc right now")
109+
110+
await vc.disconnect()
111+
112+
await ctx.respond("Left!")
113+
114+
115+
bot.run("TOKEN")

0 commit comments

Comments
 (0)