diff --git a/Cargo.toml b/Cargo.toml index 134ed10..7bb45d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ songbird = { version = "0.6.0", default-features = false, features = [ "tws", ] } strum = { version = "0.28.0", features = ["derive"] } -time = { version = "0.3.47", features = ["formatting", "parsing"] } +time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] } tokio = { version = "1.46.0", features = ["rt-multi-thread", "macros", "signal", "time"] } tokio-util = { version = "0.7.18", features = ["io"] } tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [ diff --git a/src/audio_channels.rs b/src/audio_channels.rs new file mode 100644 index 0000000..66ca526 --- /dev/null +++ b/src/audio_channels.rs @@ -0,0 +1,26 @@ +use opus2::Channels; +use strum::EnumString; + +#[derive(Clone, Copy, Debug, strum::Display, EnumString)] +pub enum AudioChannels { + Mono, + Stereo, +} + +impl From for Channels { + fn from(value: AudioChannels) -> Self { + match value { + AudioChannels::Mono => Channels::Mono, + AudioChannels::Stereo => Channels::Stereo, + } + } +} + +impl From for songbird::driver::Channels { + fn from(value: AudioChannels) -> Self { + match value { + AudioChannels::Mono => songbird::driver::Channels::Mono, + AudioChannels::Stereo => songbird::driver::Channels::Stereo, + } + } +} diff --git a/src/audio_sample_rate.rs b/src/audio_sample_rate.rs new file mode 100644 index 0000000..c4f1682 --- /dev/null +++ b/src/audio_sample_rate.rs @@ -0,0 +1,34 @@ +use songbird::driver::SampleRate; +use strum::EnumString; + +#[derive(Clone, Copy, Debug, strum::Display, EnumString)] +pub enum AudioSampleRate { + #[strum(serialize = "8000Hz")] + Hz8000, + #[strum(serialize = "12000Hz")] + Hz12000, + #[strum(serialize = "16000Hz")] + Hz16000, + #[strum(serialize = "24000Hz")] + Hz24000, + #[strum(serialize = "48000Hz")] + Hz48000, +} + +impl From for SampleRate { + fn from(value: AudioSampleRate) -> Self { + match value { + AudioSampleRate::Hz8000 => SampleRate::Hz8000, + AudioSampleRate::Hz12000 => SampleRate::Hz12000, + AudioSampleRate::Hz16000 => SampleRate::Hz16000, + AudioSampleRate::Hz24000 => SampleRate::Hz24000, + AudioSampleRate::Hz48000 => SampleRate::Hz48000, + } + } +} + +impl From for u32 { + fn from(value: AudioSampleRate) -> Self { + SampleRate::from(value).into() + } +} diff --git a/src/command/join.rs b/src/command/join.rs index b302232..3b6f936 100644 --- a/src/command/join.rs +++ b/src/command/join.rs @@ -120,8 +120,8 @@ pub async fn handle(state: State, interaction: Interaction) { .expect("TODO"); match join_and_record() - .audio_channels(state.audio_channels) - .audio_sample_rate(state.audio_sample_rate) + .audio_channels(state.audio_channels.into()) + .audio_sample_rate(state.audio_sample_rate.into()) .guild_id(guild_id) .recording_manager(state.recording_manager) .songbird(&state.songbird) diff --git a/src/command/mod.rs b/src/command/mod.rs index 420a052..e6bf761 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -16,8 +16,8 @@ use twilight_model::{ }; use crate::{ - BotManager, GuildVoiceChannelToTextChannel, UserManager, VCsSender, - recording_data::RecordingManager, render_data::RenderManager, + AudioChannels, AudioSampleRate, BotManager, GuildVoiceChannelToTextChannel, UserManager, + VCsSender, recording_data::RecordingManager, render_data::RenderManager, }; pub mod info; @@ -29,8 +29,8 @@ pub mod render; #[derive(Debug, Clone)] pub struct State { - pub audio_channels: Channels, - pub audio_sample_rate: SampleRate, + pub audio_channels: AudioChannels, + pub audio_sample_rate: AudioSampleRate, pub bot_manager: BotManager, pub cancellation_token: CancellationToken, pub discord_application_id: Id, diff --git a/src/command/render.rs b/src/command/render.rs index eb908e6..d3d4819 100644 --- a/src/command/render.rs +++ b/src/command/render.rs @@ -1,7 +1,7 @@ use futures::TryStreamExt as _; use snafu::{OptionExt as _, Report, ResultExt as _, Snafu}; use std::{collections::BTreeMap, sync::LazyLock}; -use time::{UtcDateTime, format_description::well_known::Rfc3339}; +use time::{Date, Time, UtcDateTime, format_description::well_known::Rfc3339}; use twilight_model::{ application::{ command::{Command, CommandOption, CommandOptionType, CommandType}, @@ -18,7 +18,11 @@ use twilight_util::builder::{ InteractionResponseDataBuilder, command::CommandBuilder, embed::EmbedBuilder, }; -use crate::command::State; +use crate::{ + command::State, + recording_data::{Clip, Recording, RecordingData}, + render_data::{Render, RenderData}, +}; const NAME: &str = "render"; const DESCRIPTION: &str = @@ -292,7 +296,7 @@ pub async fn handle(state: State, interaction: Interaction) { let total_samples = (duration.whole_seconds() as u32 * sample_rate) + (duration.subsec_microseconds() as u32 * sample_rate / 1_000_000); - let samples = vec![0; total_samples as usize]; + let mut composite = vec![0; total_samples as usize]; let mut recordings = state @@ -306,7 +310,66 @@ pub async fn handle(state: State, interaction: Interaction) { .read(&recording) .await .expect("TODO"); - tracing::debug!(?recording, ?recording_data); + let RecordingData { + channels, + sample_rate, + samples, + } = recording_data; + + let Recording { + year, + month, + day, + hour, + minute, + clip, + } = recording; + let Clip { + second, + microsecond, + guild, + voice_channel, + user, + } = clip; + + let date = Date::from_calendar_date(year, month, day).unwrap(); + let time = Time::from_hms_micro(hour, minute, second, microsecond).unwrap(); + let datetime = UtcDateTime::new(date, time); + + let after_start = datetime - start; + + let origin = (after_start.whole_seconds() as u32 * sample_rate) + + (after_start.subsec_microseconds() as u32 * sample_rate / 1_000_000); + let origin = origin as usize; + + for (i, sample) in samples.into_iter().enumerate() { + if let Some(composite_sample) = composite.get_mut(origin + i) { + *composite_sample += sample; + } else { + tracing::error!(origin, i, total_samples, "out of range"); + } + } } + + let render = Render { + start, + end, + guild_id, + voice_channel_id, + }; + let render_data = RenderData { + channels: state.audio_channels.into(), + sample_rate, + samples: composite, + }; + + state + .render_manager + .write(&render, render_data) + .await + .expect("TODO"); + + tracing::info!(%render, "written"); + todo!(); } diff --git a/src/heat_seek.rs b/src/heat_seek.rs index 76594a0..ece7319 100644 --- a/src/heat_seek.rs +++ b/src/heat_seek.rs @@ -323,8 +323,8 @@ async fn follow_hottest_vc( match hottest_vc_option { Some(hottest_vc) => { match join_and_record() - .audio_channels(state.audio_channels) - .audio_sample_rate(state.audio_sample_rate) + .audio_channels(state.audio_channels.into()) + .audio_sample_rate(state.audio_sample_rate.into()) .guild_id(guild_id) .recording_manager(state.recording_manager.clone()) .songbird(&state.songbird) diff --git a/src/lib.rs b/src/lib.rs index e68f43a..fcf2b3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +mod audio_channels; +mod audio_sample_rate; mod bot_data; mod call; pub mod command; @@ -17,6 +19,8 @@ capnp::generated_code!(mod bot_capnp); capnp::generated_code!(mod user_capnp); shadow_rs::shadow!(build_info); +pub use audio_channels::AudioChannels; +pub use audio_sample_rate::AudioSampleRate; pub use bot_data::BotManager; pub use command::{Router as CommandRouter, State, all as all_commands}; pub use heat_seek::heat_seek; diff --git a/src/main.rs b/src/main.rs index 6bd8d4f..8608ed6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,13 @@ use clap::Parser; use fomo_reducer::{ - BotManager, CommandRouter, GuildVoiceChannelToTextChannel, RecordingManager, RenderManager, - State, Storage, UserManager, VCsSender, all_commands, command, heat_seek, initialize_vcs, - update_vcs, + AudioChannels, AudioSampleRate, BotManager, CommandRouter, GuildVoiceChannelToTextChannel, + RecordingManager, RenderManager, State, Storage, UserManager, VCsSender, all_commands, command, + heat_seek, initialize_vcs, update_vcs, }; use secrecy::{ExposeSecret, SecretString}; use snafu::{OptionExt, ResultExt, Snafu}; -use songbird::{ - Config, Songbird, - driver::{Channels, DecodeConfig, SampleRate}, - shards::TwilightMap, -}; +use songbird::{Config, Songbird, driver::DecodeConfig, shards::TwilightMap}; use std::{collections::BTreeMap, fmt::Debug, str::FromStr, sync::Arc, time::Duration}; -use strum::EnumString; use tokio::{select, signal::ctrl_c, task::JoinSet}; use tokio_util::{sync::CancellationToken, time::FutureExt as _}; use tracing::Level; @@ -33,47 +28,6 @@ use twilight_model::{ }, }; -#[derive(Clone, Copy, Debug, strum::Display, EnumString)] -enum AudioChannels { - Mono, - Stereo, -} - -impl From for Channels { - fn from(value: AudioChannels) -> Self { - match value { - AudioChannels::Mono => Channels::Mono, - AudioChannels::Stereo => Channels::Stereo, - } - } -} - -#[derive(Clone, Copy, Debug, strum::Display, EnumString)] -enum AudioSampleRate { - #[strum(serialize = "8000Hz")] - Hz8000, - #[strum(serialize = "12000Hz")] - Hz12000, - #[strum(serialize = "16000Hz")] - Hz16000, - #[strum(serialize = "24000Hz")] - Hz24000, - #[strum(serialize = "48000Hz")] - Hz48000, -} - -impl From for SampleRate { - fn from(value: AudioSampleRate) -> Self { - match value { - AudioSampleRate::Hz8000 => SampleRate::Hz8000, - AudioSampleRate::Hz12000 => SampleRate::Hz12000, - AudioSampleRate::Hz16000 => SampleRate::Hz16000, - AudioSampleRate::Hz24000 => SampleRate::Hz24000, - AudioSampleRate::Hz48000 => SampleRate::Hz48000, - } - } -} - #[derive(Debug, Snafu)] enum ParseGuildVCToTextChannelError { /// the guild ID needs to be included with : before the voice channel to text channel mapping @@ -279,15 +233,12 @@ async fn main() -> Result<(), MainError> { .collect(), ); - let audio_channels = audio_channels.into(); - let audio_sample_rate = audio_sample_rate.into(); - let senders = Arc::new(senders); let songbird = Songbird::twilight(senders, discord_user_id); songbird.set_config( Config::default().decode_mode(songbird::driver::DecodeMode::Decode(DecodeConfig::new( - audio_channels, - audio_sample_rate, + audio_channels.into(), + audio_sample_rate.into(), ))), ); diff --git a/src/recording_data/clip.rs b/src/recording_data/clip.rs index 90ecd2b..3a3e1fd 100644 --- a/src/recording_data/clip.rs +++ b/src/recording_data/clip.rs @@ -3,9 +3,8 @@ use snafu::{ResultExt as _, Snafu}; use std::{fmt::Display, str::FromStr}; use super::{ - CreateListerSnafu, Day, Guild, Hour, ListError, Microsecond, Minute, Month, - RecordingManager, Second, User, VoiceChannel, Year, guild, microsecond, second, user, - voice_channel, + CreateListerSnafu, Day, Guild, Hour, ListError, Microsecond, Minute, Month, RecordingManager, + Second, User, VoiceChannel, Year, guild, microsecond, second, user, voice_channel, }; #[derive(Debug, Clone)] diff --git a/src/render_data.rs b/src/render_data.rs index e79f223..2516776 100644 --- a/src/render_data.rs +++ b/src/render_data.rs @@ -1,4 +1,19 @@ +use std::{convert::Infallible, fmt::Display}; + use opendal::Operator; +use opus2::Application; +use songbird::driver::SampleRate; +use time::{ + UtcDateTime, + format_description::{self, StaticFormatDescription}, + macros::format_description, +}; +use twilight_model::id::{ + Id, + marker::{ChannelMarker, GuildMarker}, +}; + +use crate::{AudioChannels, AudioSampleRate}; #[derive(Debug, Clone)] pub struct RenderManager { @@ -10,3 +25,65 @@ impl RenderManager { Self { operator } } } + +#[derive(Debug, Clone)] +pub struct Render { + pub start: UtcDateTime, + pub end: UtcDateTime, + pub guild_id: Id, + pub voice_channel_id: Id, +} + +const DATE_FORMAT: StaticFormatDescription = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second].[subsecond]Z"); + +impl Display for Render { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + start, + end, + guild_id, + voice_channel_id, + } = self; + + let start = start.format(&DATE_FORMAT).unwrap(); + let end = end.format(&DATE_FORMAT).unwrap(); + + write!(f, "{guild_id}/{voice_channel_id}/{start}_{end}.opus") + } +} + +#[derive(Debug)] +pub struct RenderData { + pub channels: opus2::Channels, + pub sample_rate: u32, + pub samples: Vec, +} + +impl RenderManager { + pub async fn write( + &self, + render: &Render, + RenderData { + channels, + sample_rate, + samples, + }: RenderData, + ) -> Result< + (), + Infallible, // TODO: a real error type + > { + let mut bytes = Vec::new(); + + let mut encoder = + opus2::Encoder::new(sample_rate, channels, Application::Audio).expect("TODO"); + + encoder.encode(&samples, &mut bytes).expect("TODO"); + + let path = render.to_string(); + + self.operator.write(&path, bytes).await.expect("TODO"); + + Ok(()) + } +}