diff options
author | Santo Cariotti <santo@dcariotti.me> | 2024-09-13 13:04:16 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2024-09-13 13:04:16 +0200 |
commit | 28f4a1be06e29bb3bbb3dbf4f9307308783450ab (patch) | |
tree | cf006f2d02e31b02e9636228475095cddfb4c470 | |
parent | 50f16bb68fb6b28a1049ca25bb4273847c77ea93 (diff) |
Text-to-speach API for alert sound generation
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Cargo.lock | 1 | ||||
-rw-r--r-- | Cargo.toml | 1 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | assets/sounds/readme.md | 1 | ||||
-rw-r--r-- | docker-compose.yml | 1 | ||||
-rw-r--r-- | k8s/cas-deployment.yaml | 5 | ||||
-rw-r--r-- | k8s/cas-secret.yaml | 1 | ||||
-rw-r--r-- | src/audio.rs | 86 | ||||
-rw-r--r-- | src/config.rs | 3 | ||||
-rw-r--r-- | src/graphql/types/alert.rs | 40 | ||||
-rw-r--r-- | src/main.rs | 4 |
12 files changed, 147 insertions, 1 deletions
@@ -1 +1,2 @@ /target +assets/sounds/*.mp3 @@ -420,6 +420,7 @@ dependencies = [ "lazy_static", "once_cell", "postgis", + "reqwest", "serde", "serde_json", "sha256", @@ -31,3 +31,4 @@ sha256 = "1.5.0" axum-extra = { version = "0.9.3", features = ["typed-header"] } bytes = "1" expo_push_notification_client = "0.4.1" +reqwest = { version = "0.12.7", features = ["json"] } @@ -29,6 +29,9 @@ Now you set up some env variables: - `EXPO_ACCESS_TOKEN`: used by the [Expo](https://expo.dev) API access. +- `UNREALSPEECH_TOKEN`: used by [Unrealspeech](https://unrealspeech.com) for + text-to-speach API. + After that you must copy the `schema/init.sql` file into the database. Now just run the app @@ -64,6 +67,7 @@ docker run \ -e JWT_SECRET=... \ -e ALLOWED_HOST=... \ -e EXPO_ACCESS_TOKEN ... \ + -e UNREALSPEECH_TOKEN ... \ cas:latest ``` diff --git a/assets/sounds/readme.md b/assets/sounds/readme.md new file mode 100644 index 0000000..e4ffb4a --- /dev/null +++ b/assets/sounds/readme.md @@ -0,0 +1 @@ +Here is the sounds list diff --git a/docker-compose.yml b/docker-compose.yml index 163890c..36459d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - JWT_SECRET=${JWT_SECRET} - ALLOWED_HOST=${ALLOWED_HOST} - EXPO_ACCESS_TOKEN=${EXPO_ACCESS_TOKEN} + - UNREALSPEECH_TOKEN=${UNREALSPEECH_TOKEN} depends_on: - postgres diff --git a/k8s/cas-deployment.yaml b/k8s/cas-deployment.yaml index f070fe4..fc0289a 100644 --- a/k8s/cas-deployment.yaml +++ b/k8s/cas-deployment.yaml @@ -37,6 +37,11 @@ spec: secretKeyRef: name: cas-secret key: EXPO_ACCESS_TOKEN + - name: UNREALSPEECH_TOKEN + valueFrom: + secretKeyRef: + name: cas-secret + key: UNREALSPEECH_TOKEN - name: ALLOWED_HOST valueFrom: configMapKeyRef: diff --git a/k8s/cas-secret.yaml b/k8s/cas-secret.yaml index 268c119..a2fc3e3 100644 --- a/k8s/cas-secret.yaml +++ b/k8s/cas-secret.yaml @@ -6,3 +6,4 @@ type: Opaque data: JWT_SECRET: ${JWT_SECRET} EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN} + UNREALSPEECH_TOKEN: ${UNREALSPEECH_TOKEN} diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..64ee223 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,86 @@ +use crate::config::CONFIG; +use axum::{ + extract::Path, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, +}; +use reqwest::header::AUTHORIZATION; +use std::{ + fs::{self, File}, + io::Write, + path::Path as StdPath, +}; + +/// Create a new sound from a text +pub async fn tts(text: String, filename: String) -> Result<(), String> { + let url = "https://api.v7.unrealspeech.com/stream"; + let api_key = format!("Bearer {}", CONFIG.unrealspeech_token); + let filepath = format!("./assets/sounds/{}", filename); + + // Request JSON body + let body = serde_json::json!({ + "Text": text, + "VoiceId": "Will", + "Bitrate": "192k", + "Speed": "0", + "Pitch": "0.92", + "Codec": "libmp3lame", + }); + + // Send POST request + let client = reqwest::Client::new(); + let response = client + .post(url) + .header(AUTHORIZATION, api_key) + .json(&body) + .send() + .await + .unwrap(); + + // Check for successful response + if response.status().is_success() { + let mut file = File::create(filepath).unwrap(); + let content = response.bytes().await.unwrap(); + let _ = file.write_all(&content); + Ok(()) + } else { + Err(format!("Failed to fetch the audio: {}", response.status())) + } +} + +/// Axum endpoint which shows files +pub async fn show_file( + Path(id): Path<String>, +) -> Result<(HeaderMap, Vec<u8>), (StatusCode, String)> { + let index = id.find('.').unwrap_or(usize::MAX); + + let mut ext_name = "xxx"; + if index != usize::MAX { + ext_name = &id[index + 1..]; + } + + let mut headers = HeaderMap::new(); + + if ["mp3"].contains(&ext_name) { + let content_type = "audio/mpeg"; + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_str(content_type).unwrap(), + ); + } + + let file_name = format!("./assets/sounds/{}", id); + let file_path = StdPath::new(&file_name); + + if !file_path.exists() { + return Err((StatusCode::NOT_FOUND, "File not found".to_string())); + } + + // Read the file and return its content + match fs::read(file_path) { + Ok(file_content) => Ok((headers, file_content)), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to read file".to_string(), + )), + } +} diff --git a/src/config.rs b/src/config.rs index a703010..763b852 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,9 @@ pub struct Configuration { /// Token used by Expo API to send a notification pub expo_access_token: String, + + /// Token used for text-to-speach API + pub unrealspeech_token: String, } impl Configuration { diff --git a/src/graphql/types/alert.rs b/src/graphql/types/alert.rs index 6be723c..2ee0087 100644 --- a/src/graphql/types/alert.rs +++ b/src/graphql/types/alert.rs @@ -132,6 +132,8 @@ pub mod query { } pub mod mutations { + use crate::audio; + use super::*; /// Create a new alert @@ -330,6 +332,44 @@ pub mod mutations { .await .unwrap(); + if let Err(e) = audio::tts( + alert.text1.clone(), + format!("alert-{}-text-1.mp3", alert.id), + ) + .await + { + eprintln!( + "Error for `{}`: {}", + format!("alert-{}-text-1.mp3", alert.id), + e + ); + } + + if let Err(e) = audio::tts( + alert.text2.clone(), + format!("alert-{}-text-2.mp3", alert.id), + ) + .await + { + eprintln!( + "Error for `{}`: {}", + format!("alert-{}-text-2.mp3", alert.id), + e + ); + } + if let Err(e) = audio::tts( + alert.text3.clone(), + format!("alert-{}-text-3.mp3", alert.id), + ) + .await + { + eprintln!( + "Error for `{}`: {}", + format!("alert-{}-text-3.mp3", alert.id), + e + ); + } + Ok(alert) } } diff --git a/src/main.rs b/src/main.rs index 33718c1..940bb5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![doc = include_str!("../README.md")] +mod audio; mod config; mod db; mod errors; @@ -13,7 +14,7 @@ use crate::config::CONFIG; use async_graphql::{EmptySubscription, Schema}; use axum::{ http::{header, Method, Request}, - routing::post, + routing::{get, post}, Extension, Router, }; use tokio::net::TcpListener; @@ -45,6 +46,7 @@ async fn create_app() -> Router { .finish(); Router::new() + .route("/assets/sounds/:id", get(audio::show_file)) .route( "/graphql", post(graphql::routes::graphql_handler).layer(Extension(schema.clone())), |