Skip to content

Commit

Permalink
Implement new audio API (#18)
Browse files Browse the repository at this point in the history
* Implement AudioClient with new voice API

* Change sample audio application type

* Adjust multiple audio client supports.

* Adjust music queue

* Code clean up
  • Loading branch information
gehongyan authored Jul 12, 2024
1 parent 20e70be commit e766bf6
Show file tree
Hide file tree
Showing 39 changed files with 684 additions and 1,214 deletions.
144 changes: 94 additions & 50 deletions samples/Kook.Net.Samples.Audio/Modules/MusicModule.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using Kook.Audio;
using Kook.Commands;
using Kook.Net.Samples.Audio.Services;
using Kook.WebSocket;
Expand All @@ -13,8 +12,6 @@ namespace Kook.Net.Samples.Audio.Modules;
/// </summary>
public class MusicModule : ModuleBase<SocketCommandContext>
{
private static readonly Regex markdownRegex = new(@"\[(?<text>.+?)\]\((?<url>.+?)\)", RegexOptions.Compiled);

private readonly MusicService _musicService;
private readonly IHttpClientFactory _httpClientFactory;

Expand All @@ -31,43 +28,30 @@ public MusicModule(MusicService musicService, IHttpClientFactory httpClientFacto
[RequireContext(ContextType.Guild)]
public async Task JoinAsync()
{
if (Context.User is not SocketGuildUser user
|| await user.GetConnectedVoiceChannelsAsync() is not { Count: > 0 } voiceChannels)
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("You must be in a voice channel to use this command.");
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

SocketVoiceChannel voiceChannel = voiceChannels.First();
if (voiceChannel.ConnectedUsers.Contains(Context.Guild?.CurrentUser))
{
await ReplyTextAsync("I'm already connected to this voice channel.");
return;
}

IAudioClient? audioClient = await voiceChannel.ConnectAsync();
if (audioClient is null)
{
await ReplyTextAsync("Failed to connect to the voice channel.");
return;
}

_musicService.SetAudioClient(Context.Channel, audioClient);
await _musicService.ConnectAsync(voiceChannel);
await ReplyTextAsync($"Connected to {voiceChannel.Name}.");
}

[Command("leave")]
[RequireContext(ContextType.Guild)]
public async Task LeaveAsync()
{
if (Context.Guild?.AudioClient?.ConnectionState != ConnectionState.Connected)
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("I'm not connected to a voice channel.");
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

await Context.Guild.AudioClient.StopAsync();
await ReplyTextAsync($"Disconnected.");
await _musicService.DisconnectAsync(voiceChannel);
await ReplyTextAsync("Disconnected.");
}

// [Command("search")]
Expand Down Expand Up @@ -128,57 +112,98 @@ public async Task LeaveAsync()

[Command("add")]
[RequireContext(ContextType.Guild)]
public async Task AddAsync([Remainder] string url)
public async Task AddAsync([Remainder] Uri url)
{
if (Context.Guild?.AudioClient?.ConnectionState != ConnectionState.Connected)
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("I'm not connected to a voice channel.");
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

string rawContent = markdownRegex.Replace(url, "$1");
Uri? parsed = await ConvertUriAsync(rawContent);
Uri? parsed = await ConvertSongUriAsync(url.ToString());
if (parsed is null)
{
await ReplyTextAsync("Invalid URL.");
await ReplyTextAsync("Failed to convert the URL to a direct link.");
return;
}

_musicService.Enqueue(parsed);
_musicService.Enqueue(voiceChannel, parsed);
await ReplyTextAsync($"Added: {parsed}");
}

[Command("addlist")]
[RequireContext(ContextType.Guild)]
public async Task AddListAsync([Remainder] Uri url)
{
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

List<Uri>? parsed = await ConvertSongListUriAsync(url.ToString());
if (parsed is null)
{
await ReplyTextAsync("Failed to passe the URL to a link representing a song list.");
return;
}

_musicService.Enqueue(voiceChannel, parsed);
await ReplyTextAsync($"Added: {parsed.Count} songs.");
}

[Command("addraw")]
[Alias("raw")]
[RequireContext(ContextType.Guild)]
public async Task AddRawAsync([Remainder] Uri url)
{
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

_musicService.Enqueue(voiceChannel, url);
await ReplyTextAsync($"Added: {url}");
}

[Command("skip")]
[RequireContext(ContextType.Guild)]
public async Task SkipAsync()
{
if (Context.Guild?.AudioClient?.ConnectionState != ConnectionState.Connected)
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("I'm not connected to a voice channel.");
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

_musicService.Skip();
_musicService.Skip(voiceChannel);
await ReplyTextAsync("Skipped.");
}

[Command("list")]
[RequireContext(ContextType.Guild)]
public async Task ListAsync()
{
if (Context.Guild?.AudioClient?.ConnectionState != ConnectionState.Connected)
if (Context.Message.Source is not MessageSource.User) return;
if (Context.Channel is not SocketVoiceChannel voiceChannel)
{
await ReplyTextAsync("I'm not connected to a voice channel.");
await ReplyTextAsync("You muse use this command in a voice channel.");
return;
}

await ReplyTextAsync(string.Join(Environment.NewLine, _musicService.Queue.Select((x, i) => $"[{i}] {x}")));
IEnumerable<Uri> queue = _musicService.GetQueue(voiceChannel);
await ReplyTextAsync(string.Join(Environment.NewLine, queue.Select((x, i) => $"[{i}] {x}")));
}

private static readonly Regex neteaseSongPageRegex = new(@"^https://music.163.com/#/song\?id=(?<id>\d+)$", RegexOptions.Compiled);
private static readonly Regex neteaseSongDirectRegex = new(@"^https://music.163.com/song/media/outer/url\?id=(?<id>\d+).mp3$", RegexOptions.Compiled);
private static readonly Regex qqSongPageRegex = new(@"^https://y.qq.com/n/ryqq/songDetail/(?<id>\w+)$", RegexOptions.Compiled);
private async Task<Uri?> ConvertUriAsync(string url)
private static readonly Regex neteaseSongPageRegex = new(@"^https://music.163.com(/#)?/song\?id=(?<id>\d+)(&.*)?$", RegexOptions.Compiled);
private static readonly Regex qqSongPageRegex = new(@"^https://y.qq.com/n/ryqq/songDetail/(?<id>\w+)(&.*)?$", RegexOptions.Compiled);

private async Task<Uri?> ConvertSongUriAsync(string url)
{
// 网易云音乐歌曲页面
Match match = neteaseSongPageRegex.Match(url);
Expand All @@ -188,14 +213,6 @@ public async Task ListAsync()
return new Uri($"https://music.163.com/song/media/outer/url?id={id}.mp3");
}

// 网易云音乐直链
match = neteaseSongDirectRegex.Match(url);
if (match.Success)
{
string id = match.Groups["id"].Value;
return new Uri($"https://music.163.com/song/media/outer/url?id={id}.mp3");
}

// QQ 音乐歌曲页面
match = qqSongPageRegex.Match(url);
if (match.Success)
Expand All @@ -219,4 +236,31 @@ public async Task ListAsync()

return null;
}

private static readonly Regex neteaseSongListPageRegex = new(@"^https://music.163.com(/#)?/playlist\?id=(?<id>\d+)(&.*)?$", RegexOptions.Compiled);

private async Task<List<Uri>?> ConvertSongListUriAsync(string url)
{
// 网易云歌单页面
Match match = neteaseSongListPageRegex.Match(url);
if (match.Success)
{
string id = match.Groups["id"].Value;
HttpClient httpClient = _httpClientFactory.CreateClient("Music");
JsonDocument? response = await httpClient.GetFromJsonAsync<JsonDocument>(
$"https://music.163.com/api/playlist/detail?id={id}");
if (response is null) return null;
if (response.RootElement.GetProperty("code").GetInt32() != 200)
throw new InvalidOperationException(response.RootElement.GetProperty("message").ToString());
List<Uri> songs = response.RootElement
.GetProperty("result").GetProperty("tracks").EnumerateArray()
.Select(x => x.GetProperty("id").ToString())
.Select(x => $"https://music.163.com/song/media/outer/url?id={x}.mp3")
.Select(x => new Uri(x))
.ToList();
return songs;
}

return null;
}
}
2 changes: 1 addition & 1 deletion samples/Kook.Net.Samples.Audio/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null);
builder.Services.AddSingleton(_ => new KookSocketClient(new KookSocketConfig
{
LogLevel = LogSeverity.Debug,
LogLevel = LogSeverity.Verbose,
MessageCacheSize = 100
}));
builder.Services.AddHostedService<KookClientService>();
Expand Down
99 changes: 99 additions & 0 deletions samples/Kook.Net.Samples.Audio/Services/MusicClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Kook.Audio;
using Kook.WebSocket;

namespace Kook.Net.Samples.Audio.Services;

public class MusicClient
{
private readonly BlockingCollection<Uri> _musicQueue = [];
private readonly SocketVoiceChannel _voiceChannel;
private readonly IAudioClient _audioClient;
private int? _ffmpegProcessId;
private readonly CancellationTokenSource _cancellationToken = new();

public IEnumerable<Uri> Queue => _musicQueue;

public Uri? CurrentPlaying { get; private set; }

public MusicClient(SocketVoiceChannel voiceChannel, IAudioClient audioClient)
{
_voiceChannel = voiceChannel;
_audioClient = audioClient;
_ = Task.Factory.StartNew(StartAsync, TaskCreationOptions.LongRunning);
}

public void Enqueue(Uri source)
{
_musicQueue.Add(source);
}

public void Skip() => KillCurrentProcess();

private void KillCurrentProcess()
{
if (!_ffmpegProcessId.HasValue) return;
try
{
int processIdToKill = _ffmpegProcessId.Value;
_ffmpegProcessId = null;
Process process = Process.GetProcessById(processIdToKill);
process.Kill();
}
catch (ArgumentException)
{
// ignored
}
}

public async Task StartAsync()
{
await Task.Yield();
try
{
foreach (Uri source in _musicQueue.GetConsumingEnumerable(_cancellationToken.Token))
await PlayAsync(source, _cancellationToken.Token);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}

public async Task StopAsync()
{
await _cancellationToken.CancelAsync();
_musicQueue.Dispose();
KillCurrentProcess();
}

private async Task PlayAsync(Uri source, CancellationToken cancellationToken)
{
if (_audioClient?.ConnectionState != ConnectionState.Connected
|| _voiceChannel is null)
return;
CurrentPlaying = source;
_ = _voiceChannel.SendTextAsync($"Now playing {source}");
using Process? ffmpeg = Process.Start(new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = $"""-hide_banner -loglevel panic -i "{source}" -ac 2 -f s16le -ar 48000 pipe:1""",
UseShellExecute = false,
RedirectStandardOutput = true,
});
if (ffmpeg is null) return;
_ffmpegProcessId = ffmpeg.Id;
await using Stream output = ffmpeg.StandardOutput.BaseStream;
await using AudioOutStream kook = _audioClient.CreatePcmStream(AudioApplication.Music);
try
{
await output.CopyToAsync(kook, cancellationToken);
}
finally
{
await kook.FlushAsync(cancellationToken);
CurrentPlaying = null;
}
}
}
Loading

0 comments on commit e766bf6

Please sign in to comment.