Initial commit
+ Working with tap!
This commit is contained in:
commit
2d621929e3
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
config.toml
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
11
.idea/denny-jack.iml
generated
Normal file
11
.idea/denny-jack.iml
generated
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
8
.idea/misc.xml
generated
Normal file
8
.idea/misc.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CMakeSettings">
|
||||||
|
<configurations>
|
||||||
|
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" ENABLED="true" />
|
||||||
|
</configurations>
|
||||||
|
</component>
|
||||||
|
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/denny-jack.iml" filepath="$PROJECT_DIR$/.idea/denny-jack.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
4656
Cargo.lock
generated
Normal file
4656
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "denny-jack"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5.45", features = ["derive"] }
|
||||||
|
config = "0.15.14"
|
||||||
|
poise = "0.6.1"
|
||||||
|
serde = "1.0.219"
|
||||||
|
songbird = { version = "0.5.0", features = ["builtin-queue", "driver"] }
|
||||||
|
thiserror = "2.0.15"
|
||||||
|
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
error = "0.1.9"
|
||||||
|
log = "0.4.27"
|
||||||
|
audiopus = "0.3.0-rc.0"
|
||||||
|
|
||||||
|
[dependencies.symphonia]
|
||||||
|
version = "0.5"
|
||||||
|
features = ["aac", "mp3", "isomp4", "alac"] # ...as well as any extras you need!
|
28
src/dj_config.rs
Normal file
28
src/dj_config.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use config::{Config, File};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about = "DENNY JACK IS IN THE HOUSE", long_about = None)]
|
||||||
|
pub struct Args {
|
||||||
|
/// Path to config file
|
||||||
|
pub config: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct DJConfig {
|
||||||
|
pub bot_token: String,
|
||||||
|
pub server_addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DJConfig {
|
||||||
|
pub fn new(config_path: &Path) -> Result<Self, config::ConfigError> {
|
||||||
|
let cfg = Config::builder()
|
||||||
|
.add_source(File::from(config_path))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
cfg.try_deserialize()
|
||||||
|
}
|
||||||
|
}
|
135
src/main.rs
Normal file
135
src/main.rs
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
mod dj_config;
|
||||||
|
mod stream;
|
||||||
|
mod udp_source;
|
||||||
|
|
||||||
|
use crate::dj_config::{Args, DJConfig};
|
||||||
|
use crate::stream::Stream;
|
||||||
|
use crate::udp_source::UdpSource;
|
||||||
|
use clap::Parser;
|
||||||
|
use log::error;
|
||||||
|
use poise::{PrefixFrameworkOptions, serenity_prelude as serenity};
|
||||||
|
use songbird::SerenityInit;
|
||||||
|
use songbird::input::RawAdapter;
|
||||||
|
use std::time::Duration;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use tracing_subscriber::filter::LevelFilter;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Discord error: {0}")]
|
||||||
|
DiscordError(#[from] poise::serenity_prelude::Error),
|
||||||
|
#[error("Songbird error: {0}")]
|
||||||
|
JoinError(#[from] songbird::error::JoinError),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Data {
|
||||||
|
config: DJConfig,
|
||||||
|
}
|
||||||
|
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||||
|
|
||||||
|
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||||
|
async fn join(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let (guild_id, channel_id) = {
|
||||||
|
let guild = ctx.guild().unwrap();
|
||||||
|
let channel_id = guild
|
||||||
|
.voice_states
|
||||||
|
.get(&ctx.author().id)
|
||||||
|
.and_then(|voice_state| voice_state.channel_id);
|
||||||
|
|
||||||
|
(guild.id, channel_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let connect_to = match channel_id {
|
||||||
|
Some(channel) => channel,
|
||||||
|
None => {
|
||||||
|
ctx.reply("Not in a voice channel").await?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let manager = songbird::get(ctx.serenity_context())
|
||||||
|
.await
|
||||||
|
.expect("Songbird Voice client placed in at initialisation.")
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
manager.join(guild_id, connect_to).await?;
|
||||||
|
|
||||||
|
if let Some(handler_lock) = manager.get(guild_id) {
|
||||||
|
let mut handler = handler_lock.lock().await;
|
||||||
|
|
||||||
|
let udp_input = UdpSocket::bind(ctx.data().config.server_addr)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let stream = Stream::new();
|
||||||
|
|
||||||
|
let mut udp_source = UdpSource::new(udp_input, stream.clone());
|
||||||
|
|
||||||
|
tokio::spawn(async move { udp_source.worker().await });
|
||||||
|
|
||||||
|
let adapter = RawAdapter::new(stream.clone(), 48000, 2);
|
||||||
|
|
||||||
|
let _track = handler.play_only_input(adapter.into());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1000)).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.reply("Can't play you sounds out of a voice channel pal")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let cfg = match DJConfig::new(&args.config) {
|
||||||
|
Ok(cfg) => cfg,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Unable to open config: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let intents =
|
||||||
|
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT;
|
||||||
|
|
||||||
|
let data = Data {
|
||||||
|
config: cfg.clone(),
|
||||||
|
};
|
||||||
|
let framework = poise::Framework::builder()
|
||||||
|
.options(poise::FrameworkOptions {
|
||||||
|
commands: vec![join()],
|
||||||
|
prefix_options: PrefixFrameworkOptions {
|
||||||
|
prefix: Some("!".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.setup(|ctx, _ready, framework| {
|
||||||
|
Box::pin(async move {
|
||||||
|
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||||
|
Ok(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let client = serenity::ClientBuilder::new(cfg.bot_token, intents)
|
||||||
|
.framework(framework)
|
||||||
|
.register_songbird()
|
||||||
|
.await;
|
||||||
|
client.unwrap().start().await.unwrap();
|
||||||
|
}
|
82
src/stream.rs
Normal file
82
src/stream.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
use songbird::input::core::io::MediaSource;
|
||||||
|
use std::io::SeekFrom;
|
||||||
|
use std::{
|
||||||
|
io::{Read, Seek, Write},
|
||||||
|
sync::{Arc, Condvar, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The lower the value, the less latency
|
||||||
|
///
|
||||||
|
/// Too low of a value results in jittery audio
|
||||||
|
const BUFFER_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Stream {
|
||||||
|
inner: Arc<(Mutex<Vec<u8>>, Condvar)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for Stream {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
let (mutex, condvar) = &*self.inner;
|
||||||
|
let mut buffer = mutex.lock().expect("Mutex was poisoned");
|
||||||
|
|
||||||
|
// Prevent Discord jitter by filling buffer with zeroes if we don't have any audio
|
||||||
|
// (i.e. when you skip too far ahead in a song which hasn't been downloaded yet)
|
||||||
|
if buffer.is_empty() {
|
||||||
|
buf.fill(0);
|
||||||
|
condvar.notify_all();
|
||||||
|
|
||||||
|
return Ok(buf.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_read = usize::min(buf.len(), buffer.len());
|
||||||
|
|
||||||
|
buf[0..max_read].copy_from_slice(&buffer[0..max_read]);
|
||||||
|
buffer.drain(0..max_read);
|
||||||
|
condvar.notify_all();
|
||||||
|
|
||||||
|
Ok(max_read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for Stream {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
let (mutex, condvar) = &*self.inner;
|
||||||
|
let mut buffer = mutex.lock().expect("Mutex was poisoned");
|
||||||
|
|
||||||
|
while buffer.len() + buf.len() > BUFFER_SIZE {
|
||||||
|
buffer = condvar.wait(buffer).expect("Mutex was poisoned");
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.extend_from_slice(buf);
|
||||||
|
condvar.notify_all();
|
||||||
|
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for Stream {
|
||||||
|
fn seek(&mut self, _pos: SeekFrom) -> std::io::Result<u64> {
|
||||||
|
Err(std::io::ErrorKind::Unsupported.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for Stream {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
54
src/udp_source.rs
Normal file
54
src/udp_source.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
use crate::stream::Stream;
|
||||||
|
use audiopus::coder::Decoder;
|
||||||
|
use audiopus::{Channels, MutSignals, SampleRate};
|
||||||
|
use std::io::Write;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
pub const SAMPLE_RATE: SampleRate = SampleRate::Hz48000;
|
||||||
|
pub const SAMPLE_RATE_RAW: usize = 48_000;
|
||||||
|
pub const AUDIO_FRAME_RATE: usize = 50;
|
||||||
|
pub const MONO_FRAME_SIZE: usize = SAMPLE_RATE_RAW / AUDIO_FRAME_RATE;
|
||||||
|
pub const STEREO_FRAME_SIZE: usize = 2 * MONO_FRAME_SIZE;
|
||||||
|
|
||||||
|
pub struct UdpSource {
|
||||||
|
udp: UdpSocket,
|
||||||
|
stream: Stream,
|
||||||
|
decoder: Decoder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpSource {
|
||||||
|
pub fn new(udp_socket: UdpSocket, stream: Stream) -> Self {
|
||||||
|
Self {
|
||||||
|
udp: udp_socket,
|
||||||
|
stream,
|
||||||
|
decoder: Decoder::new(SAMPLE_RATE, Channels::Stereo).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn worker(&mut self) {
|
||||||
|
let mut buffer = vec![0; 1024 * 32];
|
||||||
|
loop {
|
||||||
|
let len = self.udp.recv(&mut buffer).await.unwrap();
|
||||||
|
|
||||||
|
let packet = audiopus::packet::Packet::try_from(&buffer[12..len]).unwrap();
|
||||||
|
|
||||||
|
let mut samples = vec![0.0; STEREO_FRAME_SIZE];
|
||||||
|
let signals = MutSignals::try_from(&mut samples).unwrap();
|
||||||
|
let sample_size = self
|
||||||
|
.decoder
|
||||||
|
.decode_float(Some(packet), signals, false)
|
||||||
|
.unwrap()
|
||||||
|
* 2;
|
||||||
|
|
||||||
|
let mut sample_bytes = Vec::with_capacity(sample_size * std::mem::size_of::<f32>());
|
||||||
|
|
||||||
|
for sample in &samples[0..sample_size] {
|
||||||
|
let bytes = sample.to_le_bytes();
|
||||||
|
|
||||||
|
sample_bytes.extend_from_slice(bytes.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stream.write_all(&sample_bytes).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user