Skip to content

Commit db58d63

Browse files
committed
feat: voice message sending through gcp on exp
1 parent a69be91 commit db58d63

File tree

12 files changed

+543
-33
lines changed

12 files changed

+543
-33
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
6+
using Concentus;
7+
using Concentus.Oggfile;
8+
9+
namespace DisCatSharp.Experimental;
10+
11+
/// <summary>
12+
/// Represents various <see cref="AudioHelper" />s.
13+
/// </summary>
14+
public static class AudioHelper
15+
{
16+
/// <summary>
17+
/// Gets the audio duration in seconds.
18+
/// </summary>
19+
/// <param name="opusStream">The audio stream.</param>
20+
/// <returns>The duration in seconds.</returns>
21+
public static (Stream Stream, float DurationSeconds) GetOpusAudioDurationInSeconds(this Stream opusStream)
22+
{
23+
opusStream.Seek(0, SeekOrigin.Begin);
24+
25+
float totalSeconds = 0;
26+
27+
using var decoder = OpusCodecFactory.CreateDecoder(48000, 1);
28+
var oggIn = new OpusOggReadStream(decoder, opusStream);
29+
30+
while (oggIn.HasNextPacket)
31+
{
32+
var packet = oggIn.DecodeNextPacket();
33+
if (packet != null)
34+
totalSeconds += packet.Length / (48000f * 1);
35+
}
36+
37+
opusStream.Seek(0, SeekOrigin.Begin);
38+
39+
return totalSeconds > 1200
40+
? throw new InvalidOperationException("Voice message duration exceeds the maximum allowed length of 20 minutes.")
41+
: (opusStream, totalSeconds);
42+
}
43+
44+
/// <summary>
45+
/// Generates the waveform data.
46+
/// </summary>
47+
/// <param name="opusStream">The audio stream.</param>
48+
/// <param name="maxWaveformSize">The maximal waveform size.</param>
49+
/// <returns>The waveform as a byte array.</returns>
50+
public static (Stream Stream, byte[] Waveform) GenerateWaveformBytes(this Stream opusStream, int maxWaveformSize = 200)
51+
{
52+
opusStream.Seek(0, SeekOrigin.Begin);
53+
54+
using var decoder = OpusCodecFactory.CreateDecoder(48000, 1);
55+
var oggIn = new OpusOggReadStream(decoder, opusStream);
56+
57+
var waveformBytes = new byte[maxWaveformSize];
58+
var samples = new float[maxWaveformSize];
59+
var totalSamples = 0;
60+
61+
while (oggIn.HasNextPacket)
62+
{
63+
var packet = oggIn.DecodeNextPacket();
64+
if (packet == null)
65+
continue;
66+
67+
foreach (var t in packet)
68+
{
69+
samples[totalSamples % maxWaveformSize] += Math.Abs(t);
70+
totalSamples++;
71+
}
72+
}
73+
74+
for (var i = 0; i < maxWaveformSize; i++)
75+
waveformBytes[i] = (byte)(samples[i] / totalSamples * 255);
76+
77+
opusStream.Seek(0, SeekOrigin.Begin);
78+
79+
return (opusStream, waveformBytes);
80+
}
81+
82+
/// <summary>
83+
/// Generates the waveform bytes and calculates the duration in seconds.
84+
/// </summary>
85+
/// <param name="opusStream">The audio stream.</param>
86+
/// <returns>The generated data.</returns>
87+
public static (Stream Stream, float DurationSeconds, byte[] Waveform) GetDurationAndWaveformBytes(this Stream opusStream)
88+
{
89+
var (durationStream, duration) = GetOpusAudioDurationInSeconds(opusStream);
90+
var (waveStream, waveform) = GenerateWaveformBytes(durationStream);
91+
92+
return (waveStream, duration, waveform);
93+
}
94+
95+
/// <summary>
96+
/// Converts an input stream to Discord's expected voice message format.
97+
/// </summary>
98+
/// <param name="inputStream">The audio source stream.</param>
99+
/// <returns>The audio result stream.</returns>
100+
public static async Task<Stream> ConvertToOggOpusAsync(this Stream inputStream)
101+
{
102+
inputStream.Seek(0, SeekOrigin.Begin);
103+
var outputStream = new MemoryStream();
104+
var tempFileName = Path.GetTempFileName();
105+
await using (var fileStream = new FileStream(tempFileName, FileMode.Create, FileAccess.Write))
106+
{
107+
await inputStream.CopyToAsync(fileStream);
108+
}
109+
110+
var ffmpeg = new ProcessStartInfo
111+
{
112+
FileName = "ffmpeg",
113+
Arguments = $"-i {tempFileName} -ar 48000 -c:a libopus -b:a 28k -ac 1 -compression_level 10 -buffer_size 2048000 -f ogg -threads 8 -err_detect ignore_err pipe:1",
114+
RedirectStandardInput = true,
115+
RedirectStandardOutput = true,
116+
RedirectStandardError = true,
117+
UseShellExecute = false,
118+
CreateNoWindow = true
119+
};
120+
121+
Console.WriteLine("Starting FFmpeg process...");
122+
var process = Process.Start(ffmpeg) ?? throw new InvalidOperationException("Failed to start FFmpeg process");
123+
124+
try
125+
{
126+
process.ErrorDataReceived += (s, e) =>
127+
{
128+
if (!string.IsNullOrWhiteSpace(e.Data))
129+
Console.WriteLine($"FFmpeg error: {e.Data}");
130+
};
131+
132+
Console.WriteLine("Copying input stream to FFmpeg...");
133+
await inputStream.CopyToAsync(process.StandardInput.BaseStream);
134+
process.StandardInput.Close();
135+
136+
Console.WriteLine("Reading output stream from FFmpeg...");
137+
await process.StandardOutput.BaseStream.CopyToAsync(outputStream);
138+
process.StandardOutput.Close();
139+
140+
Console.WriteLine("Waiting for FFmpeg process to exit...");
141+
var processTask = process.WaitForExitAsync();
142+
if (await Task.WhenAny(processTask, Task.Delay(TimeSpan.FromMinutes(5))) == processTask)
143+
Console.WriteLine("FFmpeg process completed successfully.");
144+
else
145+
{
146+
process.Kill();
147+
throw new TimeoutException("FFmpeg process timed out.");
148+
}
149+
150+
if (process.ExitCode != 0)
151+
throw new InvalidOperationException($"FFmpeg process failed with exit code {process.ExitCode}.");
152+
}
153+
catch (Exception ex)
154+
{
155+
Console.WriteLine($"An error occurred during the FFmpeg process: {ex.Message}");
156+
throw;
157+
}
158+
finally
159+
{
160+
outputStream.Seek(0, SeekOrigin.Begin);
161+
process.Dispose();
162+
await inputStream.DisposeAsync();
163+
File.Delete(tempFileName);
164+
}
165+
166+
return outputStream;
167+
}
168+
}

DisCatSharp.Experimental/DisCatSharp.Experimental.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
</PropertyGroup>
2727

2828
<ItemGroup>
29+
<PackageReference Include="Concentus" Version="2.2.2" />
30+
<PackageReference Include="Concentus.Oggfile" Version="1.0.6" />
2931
<PackageReference Include="DisCatSharp.Analyzer.Roselyn" Version="6.2.5">
3032
<PrivateAssets>all</PrivateAssets>
3133
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3234
</PackageReference>
3335
<PackageReference Include="DisCatSharp.Attributes" Version="10.6.6" />
36+
<PackageReference Include="NAudio" Version="2.2.1" />
3437
</ItemGroup>
3538

3639
<ItemGroup>

DisCatSharp.Experimental/Entities/Channel/DiscordChannelMethodHooks.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public static async Task<GcpAttachmentUploadInformation> UploadFileAsync(this Di
2525
GcpAttachment attachment = new(name, stream);
2626
var response = await hook.RequestFileUploadAsync(channel.Id, attachment).ConfigureAwait(false);
2727
var target = response.Attachments.First();
28-
_ = Task.Run(() => hook.UploadGcpFile(target, stream));
28+
hook.UploadGcpFile(target, stream);
2929
target.Filename = name;
3030
target.Description = description;
3131
return target;

DisCatSharp.Experimental/Entities/Message/DiscordMessageBuilderMethodHooks.cs

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using System;
2+
using System.IO;
3+
14
using DisCatSharp.Entities;
25

36
namespace DisCatSharp.Experimental.Entities;
@@ -12,15 +15,139 @@ public static class DiscordMessageBuilderMethodHooks
1215
/// </summary>
1316
/// <param name="builder">The <see cref="DiscordMessageBuilder" /> to add the attachment to.</param>
1417
/// <param name="gcpAttachment">The attachment to add.</param>
18+
/// <param name="isVoice">Whether this is a voice message attachment.</param>
19+
/// <param name="originalStream">The voice message's stream, required if <paramref name="isVoice"/> is <see langword="true"/>.</param>
1520
/// <returns>The chained <see cref="DiscordMessageBuilder" />.</returns>
16-
public static DiscordMessageBuilder AddGcpAttachment(this DiscordMessageBuilder builder, GcpAttachmentUploadInformation gcpAttachment)
21+
public static DiscordMessageBuilder AddGcpAttachment(this DiscordMessageBuilder builder, GcpAttachmentUploadInformation gcpAttachment, bool isVoice = false, Stream? originalStream = null)
22+
{
23+
if (!isVoice)
24+
builder.AttachmentsInternal.Add(new()
25+
{
26+
Filename = gcpAttachment.Filename,
27+
UploadedFilename = gcpAttachment.UploadFilename,
28+
Description = gcpAttachment.Description
29+
});
30+
else
31+
{
32+
ArgumentNullException.ThrowIfNull(originalStream, nameof(originalStream));
33+
var (_, durationSeconds, waveform) = originalStream.GetDurationAndWaveformBytes();
34+
Console.WriteLine($"Waveform length: {waveform.Length} bytes");
35+
builder.AttachmentsInternal.Add(new()
36+
{
37+
Filename = gcpAttachment.Filename,
38+
UploadedFilename = gcpAttachment.UploadFilename,
39+
Description = gcpAttachment.Description,
40+
DurationSecs = durationSeconds,
41+
WaveForm = waveform
42+
});
43+
builder.AsVoiceMessage();
44+
}
45+
46+
return builder;
47+
}
48+
49+
/// <summary>
50+
/// Adds a <see cref="GcpAttachment" /> to the <see cref="DiscordInteractionResponseBuilder" />.
51+
/// </summary>
52+
/// <param name="builder">The <see cref="DiscordInteractionResponseBuilder" /> to add the attachment to.</param>
53+
/// <param name="gcpAttachment">The attachment to add.</param>
54+
/// <returns>The chained <see cref="DiscordInteractionResponseBuilder" />.</returns>
55+
public static DiscordInteractionResponseBuilder AddGcpAttachment(this DiscordInteractionResponseBuilder builder, GcpAttachmentUploadInformation gcpAttachment)
56+
{
57+
var isVoice = false;
58+
Stream? originalStream = null;
59+
if (!isVoice)
60+
builder.AttachmentsInternal.Add(new()
61+
{
62+
Filename = gcpAttachment.Filename,
63+
UploadedFilename = gcpAttachment.UploadFilename,
64+
Description = gcpAttachment.Description
65+
});
66+
else
67+
{
68+
ArgumentNullException.ThrowIfNull(originalStream, nameof(originalStream));
69+
var (_, durationSeconds, waveform) = originalStream.GetDurationAndWaveformBytes();
70+
Console.WriteLine($"Waveform length: {waveform.Length} bytes");
71+
builder.AttachmentsInternal.Add(new()
72+
{
73+
Filename = gcpAttachment.Filename,
74+
UploadedFilename = gcpAttachment.UploadFilename,
75+
Description = gcpAttachment.Description,
76+
DurationSecs = durationSeconds,
77+
WaveForm = waveform
78+
});
79+
builder.AsVoiceMessage();
80+
}
81+
82+
return builder;
83+
}
84+
85+
/// <summary>
86+
/// Adds a <see cref="GcpAttachment" /> to the <see cref="DiscordWebhookBuilder" />.
87+
/// </summary>
88+
/// <param name="builder">The <see cref="DiscordWebhookBuilder" /> to add the attachment to.</param>
89+
/// <param name="gcpAttachment">The attachment to add.</param>
90+
/// <returns>The chained <see cref="DiscordWebhookBuilder" />.</returns>
91+
public static DiscordWebhookBuilder AddGcpAttachment(this DiscordWebhookBuilder builder, GcpAttachmentUploadInformation gcpAttachment)
92+
{
93+
var isVoice = false;
94+
Stream? originalStream = null;
95+
if (!isVoice)
96+
builder.AttachmentsInternal.Add(new()
97+
{
98+
Filename = gcpAttachment.Filename,
99+
UploadedFilename = gcpAttachment.UploadFilename,
100+
Description = gcpAttachment.Description
101+
});
102+
else
103+
{
104+
ArgumentNullException.ThrowIfNull(originalStream, nameof(originalStream));
105+
var (_, durationSeconds, waveform) = originalStream.GetDurationAndWaveformBytes();
106+
builder.AttachmentsInternal.Add(new()
107+
{
108+
Filename = gcpAttachment.Filename,
109+
UploadedFilename = gcpAttachment.UploadFilename,
110+
Description = gcpAttachment.Description,
111+
DurationSecs = durationSeconds,
112+
WaveForm = waveform
113+
});
114+
builder.AsVoiceMessage();
115+
}
116+
117+
return builder;
118+
}
119+
120+
/// <summary>
121+
/// Adds a <see cref="GcpAttachment" /> to the <see cref="DiscordFollowupMessageBuilder" />.
122+
/// </summary>
123+
/// <param name="builder">The <see cref="DiscordFollowupMessageBuilder" /> to add the attachment to.</param>
124+
/// <param name="gcpAttachment">The attachment to add.</param>
125+
/// <returns>The chained <see cref="DiscordFollowupMessageBuilder" />.</returns>
126+
public static DiscordFollowupMessageBuilder AddGcpAttachment(this DiscordFollowupMessageBuilder builder, GcpAttachmentUploadInformation gcpAttachment)
17127
{
18-
builder.AttachmentsInternal.Add(new()
128+
var isVoice = false;
129+
Stream? originalStream = null;
130+
if (!isVoice)
131+
builder.AttachmentsInternal.Add(new()
132+
{
133+
Filename = gcpAttachment.Filename,
134+
UploadedFilename = gcpAttachment.UploadFilename,
135+
Description = gcpAttachment.Description
136+
});
137+
else
19138
{
20-
Filename = gcpAttachment.Filename,
21-
UploadedFilename = gcpAttachment.UploadFilename,
22-
Description = gcpAttachment.Description
23-
});
139+
ArgumentNullException.ThrowIfNull(originalStream, nameof(originalStream));
140+
var (_, durationSeconds, waveform) = originalStream.GetDurationAndWaveformBytes();
141+
builder.AttachmentsInternal.Add(new()
142+
{
143+
Filename = gcpAttachment.Filename,
144+
UploadedFilename = gcpAttachment.UploadFilename,
145+
Description = gcpAttachment.Description,
146+
DurationSecs = durationSeconds,
147+
WaveForm = waveform
148+
});
149+
builder.AsVoiceMessage();
150+
}
24151

25152
return builder;
26153
}

DisCatSharp/Clients/DiscordClient.WebSocket.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ async Task SocketOnMessage(IWebSocketClient sender, SocketMessageEventArgs e)
235235
if (this.Configuration.EnableSentry)
236236
{
237237
this.Sentry.CaptureException(ex);
238-
_ = Task.Run(this.Sentry.FlushAsync);
238+
_ = Task.Run(this.Sentry.FlushAsync, this._cancelToken);
239239
}
240240
}
241241
}

0 commit comments

Comments
 (0)