feat: implement rendering

This commit is contained in:
2026-05-28 01:48:52 -04:00
parent 862a333131
commit 24ef5a67c4
11 changed files with 225 additions and 71 deletions

View File

@@ -69,7 +69,7 @@ songbird = { version = "0.6.0", default-features = false, features = [
"tws", "tws",
] } ] }
strum = { version = "0.28.0", features = ["derive"] } 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 = { version = "1.46.0", features = ["rt-multi-thread", "macros", "signal", "time"] }
tokio-util = { version = "0.7.18", features = ["io"] } tokio-util = { version = "0.7.18", features = ["io"] }
tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [ tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [

26
src/audio_channels.rs Normal file
View File

@@ -0,0 +1,26 @@
use opus2::Channels;
use strum::EnumString;
#[derive(Clone, Copy, Debug, strum::Display, EnumString)]
pub enum AudioChannels {
Mono,
Stereo,
}
impl From<AudioChannels> for Channels {
fn from(value: AudioChannels) -> Self {
match value {
AudioChannels::Mono => Channels::Mono,
AudioChannels::Stereo => Channels::Stereo,
}
}
}
impl From<AudioChannels> for songbird::driver::Channels {
fn from(value: AudioChannels) -> Self {
match value {
AudioChannels::Mono => songbird::driver::Channels::Mono,
AudioChannels::Stereo => songbird::driver::Channels::Stereo,
}
}
}

34
src/audio_sample_rate.rs Normal file
View File

@@ -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<AudioSampleRate> 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<AudioSampleRate> for u32 {
fn from(value: AudioSampleRate) -> Self {
SampleRate::from(value).into()
}
}

View File

@@ -120,8 +120,8 @@ pub async fn handle(state: State, interaction: Interaction) {
.expect("TODO"); .expect("TODO");
match join_and_record() match join_and_record()
.audio_channels(state.audio_channels) .audio_channels(state.audio_channels.into())
.audio_sample_rate(state.audio_sample_rate) .audio_sample_rate(state.audio_sample_rate.into())
.guild_id(guild_id) .guild_id(guild_id)
.recording_manager(state.recording_manager) .recording_manager(state.recording_manager)
.songbird(&state.songbird) .songbird(&state.songbird)

View File

@@ -16,8 +16,8 @@ use twilight_model::{
}; };
use crate::{ use crate::{
BotManager, GuildVoiceChannelToTextChannel, UserManager, VCsSender, AudioChannels, AudioSampleRate, BotManager, GuildVoiceChannelToTextChannel, UserManager,
recording_data::RecordingManager, render_data::RenderManager, VCsSender, recording_data::RecordingManager, render_data::RenderManager,
}; };
pub mod info; pub mod info;
@@ -29,8 +29,8 @@ pub mod render;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct State { pub struct State {
pub audio_channels: Channels, pub audio_channels: AudioChannels,
pub audio_sample_rate: SampleRate, pub audio_sample_rate: AudioSampleRate,
pub bot_manager: BotManager, pub bot_manager: BotManager,
pub cancellation_token: CancellationToken, pub cancellation_token: CancellationToken,
pub discord_application_id: Id<ApplicationMarker>, pub discord_application_id: Id<ApplicationMarker>,

View File

@@ -1,7 +1,7 @@
use futures::TryStreamExt as _; use futures::TryStreamExt as _;
use snafu::{OptionExt as _, Report, ResultExt as _, Snafu}; use snafu::{OptionExt as _, Report, ResultExt as _, Snafu};
use std::{collections::BTreeMap, sync::LazyLock}; 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::{ use twilight_model::{
application::{ application::{
command::{Command, CommandOption, CommandOptionType, CommandType}, command::{Command, CommandOption, CommandOptionType, CommandType},
@@ -18,7 +18,11 @@ use twilight_util::builder::{
InteractionResponseDataBuilder, command::CommandBuilder, embed::EmbedBuilder, 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 NAME: &str = "render";
const DESCRIPTION: &str = 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) let total_samples = (duration.whole_seconds() as u32 * sample_rate)
+ (duration.subsec_microseconds() as u32 * sample_rate / 1_000_000); + (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 = let mut recordings =
state state
@@ -306,7 +310,66 @@ pub async fn handle(state: State, interaction: Interaction) {
.read(&recording) .read(&recording)
.await .await
.expect("TODO"); .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!(); todo!();
} }

View File

@@ -323,8 +323,8 @@ async fn follow_hottest_vc(
match hottest_vc_option { match hottest_vc_option {
Some(hottest_vc) => { Some(hottest_vc) => {
match join_and_record() match join_and_record()
.audio_channels(state.audio_channels) .audio_channels(state.audio_channels.into())
.audio_sample_rate(state.audio_sample_rate) .audio_sample_rate(state.audio_sample_rate.into())
.guild_id(guild_id) .guild_id(guild_id)
.recording_manager(state.recording_manager.clone()) .recording_manager(state.recording_manager.clone())
.songbird(&state.songbird) .songbird(&state.songbird)

View File

@@ -1,3 +1,5 @@
mod audio_channels;
mod audio_sample_rate;
mod bot_data; mod bot_data;
mod call; mod call;
pub mod command; pub mod command;
@@ -17,6 +19,8 @@ capnp::generated_code!(mod bot_capnp);
capnp::generated_code!(mod user_capnp); capnp::generated_code!(mod user_capnp);
shadow_rs::shadow!(build_info); shadow_rs::shadow!(build_info);
pub use audio_channels::AudioChannels;
pub use audio_sample_rate::AudioSampleRate;
pub use bot_data::BotManager; pub use bot_data::BotManager;
pub use command::{Router as CommandRouter, State, all as all_commands}; pub use command::{Router as CommandRouter, State, all as all_commands};
pub use heat_seek::heat_seek; pub use heat_seek::heat_seek;

View File

@@ -1,18 +1,13 @@
use clap::Parser; use clap::Parser;
use fomo_reducer::{ use fomo_reducer::{
BotManager, CommandRouter, GuildVoiceChannelToTextChannel, RecordingManager, RenderManager, AudioChannels, AudioSampleRate, BotManager, CommandRouter, GuildVoiceChannelToTextChannel,
State, Storage, UserManager, VCsSender, all_commands, command, heat_seek, initialize_vcs, RecordingManager, RenderManager, State, Storage, UserManager, VCsSender, all_commands, command,
update_vcs, heat_seek, initialize_vcs, update_vcs,
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use snafu::{OptionExt, ResultExt, Snafu}; use snafu::{OptionExt, ResultExt, Snafu};
use songbird::{ use songbird::{Config, Songbird, driver::DecodeConfig, shards::TwilightMap};
Config, Songbird,
driver::{Channels, DecodeConfig, SampleRate},
shards::TwilightMap,
};
use std::{collections::BTreeMap, fmt::Debug, str::FromStr, sync::Arc, time::Duration}; 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::{select, signal::ctrl_c, task::JoinSet};
use tokio_util::{sync::CancellationToken, time::FutureExt as _}; use tokio_util::{sync::CancellationToken, time::FutureExt as _};
use tracing::Level; use tracing::Level;
@@ -33,47 +28,6 @@ use twilight_model::{
}, },
}; };
#[derive(Clone, Copy, Debug, strum::Display, EnumString)]
enum AudioChannels {
Mono,
Stereo,
}
impl From<AudioChannels> 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<AudioSampleRate> 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)] #[derive(Debug, Snafu)]
enum ParseGuildVCToTextChannelError { enum ParseGuildVCToTextChannelError {
/// the guild ID needs to be included with : before the voice channel to text channel mapping /// 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(), .collect(),
); );
let audio_channels = audio_channels.into();
let audio_sample_rate = audio_sample_rate.into();
let senders = Arc::new(senders); let senders = Arc::new(senders);
let songbird = Songbird::twilight(senders, discord_user_id); let songbird = Songbird::twilight(senders, discord_user_id);
songbird.set_config( songbird.set_config(
Config::default().decode_mode(songbird::driver::DecodeMode::Decode(DecodeConfig::new( Config::default().decode_mode(songbird::driver::DecodeMode::Decode(DecodeConfig::new(
audio_channels, audio_channels.into(),
audio_sample_rate, audio_sample_rate.into(),
))), ))),
); );

View File

@@ -3,9 +3,8 @@ use snafu::{ResultExt as _, Snafu};
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
use super::{ use super::{
CreateListerSnafu, Day, Guild, Hour, ListError, Microsecond, Minute, Month, CreateListerSnafu, Day, Guild, Hour, ListError, Microsecond, Minute, Month, RecordingManager,
RecordingManager, Second, User, VoiceChannel, Year, guild, microsecond, second, user, Second, User, VoiceChannel, Year, guild, microsecond, second, user, voice_channel,
voice_channel,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@@ -1,4 +1,19 @@
use std::{convert::Infallible, fmt::Display};
use opendal::Operator; 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)] #[derive(Debug, Clone)]
pub struct RenderManager { pub struct RenderManager {
@@ -10,3 +25,65 @@ impl RenderManager {
Self { operator } Self { operator }
} }
} }
#[derive(Debug, Clone)]
pub struct Render {
pub start: UtcDateTime,
pub end: UtcDateTime,
pub guild_id: Id<GuildMarker>,
pub voice_channel_id: Id<ChannelMarker>,
}
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<i16>,
}
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(())
}
}