This commit is contained in:
mii
2022-08-04 22:15:26 +09:00
commit 62aa5725ff
27 changed files with 589 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
Cargo.lock
/target
config.toml
credentials.json
/audio
*.mp3

8
.idea/.gitignore generated vendored Normal file
View 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

8
.idea/modules.xml generated Normal file
View 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/ncb-tts-r2.iml" filepath="$PROJECT_DIR$/.idea/ncb-tts-r2.iml" />
</modules>
</component>
</project>

11
.idea/ncb-tts-r2.iml generated Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="CPP_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>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

32
Cargo.toml Normal file
View File

@ -0,0 +1,32 @@
[package]
name = "ncb-tts-r2"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
serde_json = "1.0"
serde = "1.0"
toml = "0.5"
gcp_auth = "0.5.0"
reqwest = { version = "0.11", features = ["json"] }
base64 = "0.13"
async-trait = "0.1.57"
[dependencies.uuid]
version = "0.8"
features = ["serde", "v4"]
[dependencies.songbird]
version = "0.2.0"
features = ["builtin-queue"]
[dependencies.serenity]
version = "0.10.9"
features = ["builder", "cache", "client", "gateway", "model", "utils", "unstable_discord_api", "collector", "rustls_backend", "framework", "voice"]
[dependencies.tokio]
version = "1.0"
features = ["macros", "rt-multi-thread"]

8
src/config.rs Normal file
View File

@ -0,0 +1,8 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Config {
pub prefix: String,
pub token: String,
pub application_id: u64
}

19
src/data.rs Normal file
View File

@ -0,0 +1,19 @@
use crate::tts::gcp_tts::gcp_tts::TTS;
use serenity::{prelude::{TypeMapKey, RwLock}, model::id::GuildId, futures::lock::Mutex};
use crate::tts::instance::TTSInstance;
use std::{sync::Arc, collections::HashMap};
/// TTSInstance data
pub struct TTSData;
impl TypeMapKey for TTSData {
type Value = Arc<RwLock<HashMap<GuildId, TTSInstance>>>;
}
/// TTS client data
pub struct TTSClientData;
impl TypeMapKey for TTSClientData {
type Value = Arc<Mutex<TTS>>;
}

0
src/database/mod.rs Normal file
View File

128
src/event_handler.rs Normal file
View File

@ -0,0 +1,128 @@
use serenity::{client::{EventHandler, Context}, async_trait, model::{gateway::Ready, interactions::{Interaction, application_command::ApplicationCommandInteraction, InteractionApplicationCommandCallbackDataFlags}, id::{GuildId, UserId}, channel::Message}, framework::standard::macros::group};
use crate::{data::TTSData, tts::instance::TTSInstance};
#[group]
struct Test;
pub struct Handler;
async fn setup_command(ctx: &Context, command: &ApplicationCommandInteraction) -> Result<(), Box<dyn std::error::Error>> {
if let None = command.guild_id {
command.create_interaction_response(&ctx.http, |f| {
f.interaction_response_data(|d| {
d.content("このコマンドはサーバーでのみ使用可能です.").flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL)
})
}).await?;
return Ok(());
}
let guild = command.guild_id.unwrap().to_guild_cached(&ctx.cache).await;
if let None = guild {
command.create_interaction_response(&ctx.http, |f| {
f.interaction_response_data(|d| {
d.content("ギルドキャッシュを取得できませんでした.").flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL)
})
}).await?;
return Ok(());
}
let guild = guild.unwrap();
let channel_id = guild
.voice_states
.get(&UserId(command.user.id.0))
.and_then(|state| state.channel_id);
if let None = channel_id {
command.create_interaction_response(&ctx.http, |f| {
f.interaction_response_data(|d| {
d.content("ボイスチャンネルに参加してから実行してください.").flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL)
})
}).await?;
return Ok(());
}
let channel_id = channel_id.unwrap();
let manager = songbird::get(ctx).await.expect("Cannot get songbird client.").clone();
let storage_lock = {
let data_read = ctx.data.read().await;
data_read.get::<TTSData>().expect("Cannot get TTSStorage").clone()
};
{
let mut storage = storage_lock.write().await;
if storage.contains_key(&guild.id) {
command.create_interaction_response(&ctx.http, |f| {
f.interaction_response_data(|d| {
d.content("すでにセットアップしています.").flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL)
})
}).await?;
return Ok(());
}
storage.insert(guild.id, TTSInstance {
before_message: None,
guild: guild.id,
text_channel: command.channel_id,
voice_channel: channel_id
});
}
let _handler = manager.join(guild.id.0, channel_id.0).await;
command.create_interaction_response(&ctx.http, |f| {
f.interaction_response_data(|d| {
d.content("セットアップ完了").flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL)
})
}).await?;
Ok(())
}
#[async_trait]
impl EventHandler for Handler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::ApplicationCommand(command) = interaction {
let name = &*command.data.name;
match name {
"setup" => setup_command(&ctx, &command).await.unwrap(),
_ => {}
}
}
}
async fn message(&self, ctx: Context, message: Message) {
let guild_id = message.guild(&ctx.cache).await.unwrap().id;
let storage_lock = {
let data_read = ctx.data.read().await;
data_read.get::<TTSData>().expect("Cannot get TTSStorage").clone()
};
{
let mut storage = storage_lock.write().await;
if !storage.contains_key(&guild_id) {
return;
}
let instance = storage.get_mut(&guild_id).unwrap();
instance.read(message, &ctx).await;
}
}
async fn ready(&self, ctx: Context, ready: Ready) {
println!("{} is connected!", ready.user.name);
let guild_id = GuildId(696782998799909024);
let commands = GuildId::set_application_commands(&guild_id, &ctx.http, |commands| {
commands.create_application_command(|command| {
command.name("setup")
.description("Setup tts")
})
}).await;
println!("{:?}", commands);
}
}

70
src/implement/message.rs Normal file
View File

@ -0,0 +1,70 @@
use std::{path::Path, fs::File, io::Write};
use async_trait::async_trait;
use serenity::{prelude::Context, model::prelude::Message};
use crate::{
data::TTSClientData,
tts::{
instance::TTSInstance,
message::TTSMessage,
gcp_tts::structs::{
audio_config::AudioConfig, voice_selection_params::VoiceSelectionParams, synthesis_input::SynthesisInput, synthesize_request::SynthesizeRequest
}
},
};
#[async_trait]
impl TTSMessage for Message {
async fn parse(&self, instance: &mut TTSInstance, _: &Context) -> String {
let res = if let Some(before_message) = &instance.before_message {
if before_message.author.id == self.author.id {
self.content.clone()
} else {
format!("<speak>{} さんの発言<break time=\"200ms\"/>{}</speak>", self.author.name, self.content)
}
} else {
self.content.clone()
};
instance.before_message = Some(self.clone());
res
}
async fn synthesize(&self, instance: &mut TTSInstance, ctx: &Context) -> String {
let text = self.parse(instance, ctx).await;
let data_read = ctx.data.read().await;
let storage = data_read.get::<TTSClientData>().expect("Cannot get TTSClientStorage").clone();
let storage = storage.lock().await;
let audio = storage.synthesize(SynthesizeRequest {
input: SynthesisInput {
text: None,
ssml: Some(text)
},
voice: VoiceSelectionParams {
languageCode: String::from("ja-JP"),
name: String::from("ja-JP-Wavenet-B"),
ssmlGender: String::from("neutral")
},
audioConfig: AudioConfig {
audioEncoding: String::from("mp3"),
speakingRate: 1.2f32,
pitch: 1.0f32
}
}).await.unwrap();
let uuid = uuid::Uuid::new_v4().to_string();
let root = option_env!("CARGO_MANIFEST_DIR").unwrap();
let path = Path::new(root);
let file_path = path.join("audio").join(format!("{}.mp3", uuid));
let mut file = File::create(file_path.clone()).unwrap();
file.write(&audio).unwrap();
file_path.into_os_string().into_string().unwrap()
}
}

1
src/implement/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod message;

69
src/main.rs Normal file
View File

@ -0,0 +1,69 @@
use std::{sync::Arc, collections::HashMap};
use config::Config;
use data::{TTSData, TTSClientData};
use event_handler::Handler;
use tts::gcp_tts::gcp_tts::TTS;
use serenity::{
client::{Client, bridge::gateway::GatewayIntents},
framework::StandardFramework, prelude::RwLock, futures::lock::Mutex
};
use songbird::SerenityInit;
mod config;
mod event_handler;
mod tts;
mod implement;
mod data;
/// Create discord client
///
/// Example:
/// ```rust
/// let client = create_client("!", "BOT_TOKEN", 123456789123456789).await;
///
/// client.start().await;
/// ```
async fn create_client(prefix: &str, token: &str, id: u64) -> Result<Client, serenity::Error> {
let framework = StandardFramework::new()
.configure(|c| c
.with_whitespace(true)
.prefix(prefix));
Client::builder(token)
.event_handler(Handler)
.application_id(id)
.framework(framework)
.intents(GatewayIntents::all())
.register_songbird()
.await
}
#[tokio::main]
async fn main() {
// Load config
let config = std::fs::read_to_string("./config.toml").expect("Cannot read config file.");
let config: Config = toml::from_str(&config).expect("Cannot load config file.");
// Create discord client
let mut client = create_client(&config.prefix, &config.token, config.application_id).await.expect("Err creating client");
// Create TTS client
let tts = match TTS::new("./credentials.json".to_string()).await {
Ok(tts) => tts,
Err(err) => panic!("{}", err)
};
// Create TTS storage
{
let mut data = client.data.write().await;
data.insert::<TTSData>(Arc::new(RwLock::new(HashMap::default())));
data.insert::<TTSClientData>(Arc::new(Mutex::new(tts)));
}
// Run client
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
}
}

3
src/tts/config.rs Normal file
View File

@ -0,0 +1,3 @@
pub struct TTSConfig {
}

View File

@ -0,0 +1,57 @@
use gcp_auth::Token;
use crate::tts::gcp_tts::structs::{
synthesize_request::SynthesizeRequest,
synthesize_response::SynthesizeResponse,
};
#[derive(Clone)]
pub struct TTS {
pub token: Token
}
impl TTS {
pub async fn new(credentials_path: String) -> Result<TTS, gcp_auth::Error> {
let authenticator = gcp_auth::from_credentials_file(credentials_path).await?;
let token = authenticator.get_token(&["https://www.googleapis.com/auth/cloud-platform"]).await?;
Ok(TTS {
token
})
}
/// Synthesize text to speech and return the audio data.
///
/// Example:
/// ```rust
/// let audio = storage.synthesize(SynthesizeRequest {
/// input: SynthesisInput {
/// text: None,
/// ssml: Some(String::from("<speak>test</speak>"))
/// },
/// voice: VoiceSelectionParams {
/// languageCode: String::from("ja-JP"),
/// name: String::from("ja-JP-Wavenet-B"),
/// ssmlGender: String::from("neutral")
/// },
/// audioConfig: AudioConfig {
/// audioEncoding: String::from("mp3"),
/// speakingRate: 1.2f32,
/// pitch: 1.0f32
/// }
/// }).await.unwrap();
/// ```
pub async fn synthesize(&self, request: SynthesizeRequest) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
match client.post("https://texttospeech.googleapis.com/v1/text:synthesize")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", self.token.as_str()))
.body(serde_json::to_string(&request).unwrap())
.send().await {
Ok(ok) => {
let response: SynthesizeResponse = serde_json::from_str(&ok.text().await.expect("")).unwrap();
Ok(base64::decode(response.audioContent).unwrap()[..].to_vec())
},
Err(err) => Err(Box::new(err))
}
}
}

2
src/tts/gcp_tts/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod gcp_tts;
pub mod structs;

View File

@ -0,0 +1,17 @@
use serde::{Serialize, Deserialize};
/// Example:
/// ```rust
/// AudioConfig {
/// audioEncoding: String::from("mp3"),
/// speakingRate: 1.2f32,
/// pitch: 1.0f32
/// }
/// ```
#[derive(Serialize, Deserialize, Debug)]
#[allow(non_snake_case)]
pub struct AudioConfig {
pub audioEncoding: String,
pub speakingRate: f32,
pub pitch: f32
}

View File

@ -0,0 +1,5 @@
pub mod audio_config;
pub mod synthesis_input;
pub mod synthesize_request;
pub mod voice_selection_params;
pub mod synthesize_response;

View File

@ -0,0 +1,14 @@
use serde::{Serialize, Deserialize};
/// Example:
/// ```rust
/// SynthesisInput {
/// text: None,
/// ssml: Some(String::from("<speak>test</speak>"))
/// }
/// ```
#[derive(Serialize, Deserialize, Debug)]
pub struct SynthesisInput {
pub text: Option<String>,
pub ssml: Option<String>
}

View File

@ -0,0 +1,33 @@
use serde::{Serialize, Deserialize};
use crate::tts::gcp_tts::structs::{
synthesis_input::SynthesisInput,
audio_config::AudioConfig,
voice_selection_params::VoiceSelectionParams,
};
/// Example:
/// ```rust
/// SynthesizeRequest {
/// input: SynthesisInput {
/// text: None,
/// ssml: Some(String::from("<speak>test</speak>"))
/// },
/// voice: VoiceSelectionParams {
/// languageCode: String::from("ja-JP"),
/// name: String::from("ja-JP-Wavenet-B"),
/// ssmlGender: String::from("neutral")
/// },
/// audioConfig: AudioConfig {
/// audioEncoding: String::from("mp3"),
/// speakingRate: 1.2f32,
/// pitch: 1.0f32
/// }
/// }
/// ```
#[derive(Serialize, Deserialize, Debug)]
#[allow(non_snake_case)]
pub struct SynthesizeRequest {
pub input: SynthesisInput,
pub voice: VoiceSelectionParams,
pub audioConfig: AudioConfig,
}

View File

@ -0,0 +1,7 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
#[allow(non_snake_case)]
pub struct SynthesizeResponse {
pub audioContent: String
}

View File

@ -0,0 +1,17 @@
use serde::{Serialize, Deserialize};
/// Example:
/// ```rust
/// VoiceSelectionParams {
/// languageCode: String::from("ja-JP"),
/// name: String::from("ja-JP-Wavenet-B"),
/// ssmlGender: String::from("neutral")
/// }
/// ```
#[derive(Serialize, Deserialize, Debug)]
#[allow(non_snake_case)]
pub struct VoiceSelectionParams {
pub languageCode: String,
pub name: String,
pub ssmlGender: String
}

33
src/tts/instance.rs Normal file
View File

@ -0,0 +1,33 @@
use serenity::{model::{channel::Message, id::{ChannelId, GuildId}}, prelude::Context};
use crate::{tts::message::TTSMessage};
pub struct TTSInstance {
pub before_message: Option<Message>,
pub text_channel: ChannelId,
pub voice_channel: ChannelId,
pub guild: GuildId
}
impl TTSInstance {
/// Synthesize text to speech and send it to the voice channel.
///
/// Example:
/// ```rust
/// instance.read(message, &ctx).await;
/// ```
pub async fn read<T>(&mut self, message: T, ctx: &Context)
where T: TTSMessage
{
let path = message.synthesize(self, ctx).await;
{
let manager = songbird::get(&ctx).await.unwrap();
let call = manager.get(self.guild).unwrap();
let mut call = call.lock().await;
let input = songbird::input::ffmpeg(path).await.expect("File not found.");
call.enqueue_source(input);
}
}
}

25
src/tts/message.rs Normal file
View File

@ -0,0 +1,25 @@
use async_trait::async_trait;
use serenity::prelude::Context;
use crate::tts::instance::TTSInstance;
/// Message trait that can be used to synthesize text to speech.
#[async_trait]
pub trait TTSMessage {
/// Parse the message for synthesis.
///
/// Example:
/// ```rust
/// let text = message.parse(instance, ctx).await;
/// ```
async fn parse(&self, instance: &mut TTSInstance, ctx: &Context) -> String;
/// Synthesize the message and returns the path to the audio file.
///
/// Example:
/// ```rust
/// let path = message.synthesize(instance, ctx).await;
/// ```
async fn synthesize(&self, instance: &mut TTSInstance, ctx: &Context) -> String;
}

6
src/tts/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod gcp_tts;
pub mod voicevox;
pub mod config;
pub mod message;
pub mod tts_type;
pub mod instance;

4
src/tts/tts_type.rs Normal file
View File

@ -0,0 +1,4 @@
pub enum TTSType {
GCP,
VOICEVOX
}

0
src/tts/voicevox/mod.rs Normal file
View File