Inital version
This commit is contained in:
parent
da49e2223b
commit
b8921858f2
9 changed files with 293 additions and 32 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -20,3 +20,6 @@ Cargo.lock
|
|||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Environment
|
||||
.env
|
|
@ -2,3 +2,10 @@
|
|||
name = "compactadventure2"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
dotenv = "0.15.0"
|
||||
elevenlabs_rs = "0.6.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
tokio = "1.45.0"
|
||||
toml = "0.8.22"
|
||||
|
|
33
games/test.toml
Normal file
33
games/test.toml
Normal file
|
@ -0,0 +1,33 @@
|
|||
[meta]
|
||||
version = 2.0
|
||||
title = "Test"
|
||||
author = "Arthur Beck"
|
||||
beginning = "start"
|
||||
default_voice = "UgBBYS2sOqTuMpoF3BR0"
|
||||
|
||||
[track.start]
|
||||
text = "This is an example story in compact adventure 2!"
|
||||
[[track.start.options]]
|
||||
text = "Option 1"
|
||||
to = "track2"
|
||||
[[track.start.options]]
|
||||
text = "Option 2"
|
||||
to = "track3"
|
||||
|
||||
[track.track2]
|
||||
text = "Track 2!"
|
||||
[[track.track2.options]]
|
||||
text = "Option 2.1"
|
||||
to = "track3"
|
||||
[[track.track2.options]]
|
||||
text = "Option 2.2"
|
||||
to = "start"
|
||||
|
||||
[track.track3]
|
||||
text = "Track 3!"
|
||||
[[track.track3.options]]
|
||||
text = "Option 3.1"
|
||||
to = "start"
|
||||
[[track.track3.options]]
|
||||
text = "Option 3.2"
|
||||
to = "track2"
|
34
src/cue.rs
34
src/cue.rs
|
@ -2,42 +2,50 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub enum CueItem<'a> {
|
||||
Comment(&'a str),
|
||||
Performer(&'a str),
|
||||
Title(&'a str),
|
||||
pub enum CueItem {
|
||||
Comment(String),
|
||||
Performer(String),
|
||||
Title(String),
|
||||
File {
|
||||
path: PathBuf,
|
||||
filetype: &'a str
|
||||
filetype: String
|
||||
},
|
||||
Track {
|
||||
/// should be 99 or less
|
||||
idx: u8,
|
||||
tracktype: &'a str
|
||||
idx: usize,
|
||||
tracktype: String
|
||||
},
|
||||
Index {
|
||||
// should be 99 or less
|
||||
num: u8,
|
||||
timestamp: &'a str
|
||||
num: usize,
|
||||
timestamp: String
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CueBuilder<'a> {
|
||||
items: Vec<CueItem<'a>>
|
||||
pub struct CueBuilder {
|
||||
items: Vec<CueItem>
|
||||
}
|
||||
|
||||
impl<'a> CueBuilder<'a> {
|
||||
impl CueBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self { items: vec![] }
|
||||
}
|
||||
pub fn add(mut self, item: CueItem<'a>) -> Self {
|
||||
pub fn add(mut self, item: CueItem) -> Self {
|
||||
self.items.push(item);
|
||||
self
|
||||
}
|
||||
pub fn add_ref(&mut self, item: CueItem) {
|
||||
self.items.push(item);
|
||||
}
|
||||
pub fn finish(self) -> String {
|
||||
let mut out = String::new();
|
||||
let mut indent = 0usize;
|
||||
for item in self.items {
|
||||
if let CueItem::File{..} = item {
|
||||
indent = 0
|
||||
} else if let CueItem::Track{..} = item {
|
||||
indent = 0
|
||||
}
|
||||
out.push_str(&(" ".repeat(indent)+&match item {
|
||||
CueItem::Comment(s) => format!("REM {}", s),
|
||||
CueItem::Performer(s) => format!("PERFORMER \"{}\"", s),
|
||||
|
|
236
src/main.rs
236
src/main.rs
|
@ -1,27 +1,225 @@
|
|||
//! Create choose-your-own-adventures for CDDAs
|
||||
#![warn(missing_docs, clippy::missing_docs_in_private_items)]
|
||||
#![feature(random)]
|
||||
#![feature(path_file_prefix)]
|
||||
|
||||
use elevenlabs_rs::endpoints::genai::tts::{TextToSpeech, TextToSpeechBody};
|
||||
use elevenlabs_rs::{ElevenLabsClient, Model};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod cue;
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"{}",
|
||||
cue::CueBuilder::new()
|
||||
.add(cue::CueItem::Comment("test"))
|
||||
.add(cue::CueItem::File {
|
||||
path: PathBuf::from("./test.mp3"),
|
||||
filetype: "MP3"
|
||||
})
|
||||
.add(cue::CueItem::Track {
|
||||
idx: 1,
|
||||
tracktype: "AUDIO"
|
||||
})
|
||||
.add(cue::CueItem::Index {
|
||||
num: 1,
|
||||
timestamp: "00:00:00"
|
||||
})
|
||||
.finish()
|
||||
);
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct GameMeta {
|
||||
version: f64,
|
||||
title: Option<String>,
|
||||
author: Option<String>,
|
||||
beginning: String,
|
||||
default_voice: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct TrackOption {
|
||||
text: String,
|
||||
to: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct Track {
|
||||
title: Option<String>,
|
||||
author: Option<String>,
|
||||
text: String,
|
||||
options: Vec<TrackOption>,
|
||||
voice: Option<String>,
|
||||
#[serde(skip)]
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct Game {
|
||||
meta: GameMeta,
|
||||
track: HashMap<String, Track>,
|
||||
}
|
||||
|
||||
/// returns mp3 data
|
||||
async fn speak_text(
|
||||
client: ElevenLabsClient,
|
||||
text: String,
|
||||
voice: String,
|
||||
) -> elevenlabs_rs::Result<Vec<u8>> {
|
||||
let body = TextToSpeechBody::new(text).with_model_id(Model::ElevenTurboV2_5);
|
||||
|
||||
let endpoint = TextToSpeech::new(voice, body);
|
||||
|
||||
Ok(client.hit(endpoint).await?.to_vec()) // mp3
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> elevenlabs_rs::Result<()> {
|
||||
dotenv::dotenv().unwrap();
|
||||
let client = ElevenLabsClient::from_env()?;
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let game_path = args.get(1).expect("expected a path to a game file");
|
||||
let game = toml::from_str::<Game>(
|
||||
&std::fs::read_to_string(game_path).expect("failed to read game file"),
|
||||
)
|
||||
.expect("failed to parse game file");
|
||||
|
||||
assert_eq!(game.meta.version, 2.0, "Invalid version");
|
||||
|
||||
let mut out_cue = cue::CueBuilder::new();
|
||||
out_cue.add_ref(cue::CueItem::Comment(
|
||||
"Created by CompactAdventure2".to_string(),
|
||||
));
|
||||
|
||||
if let Some(title) = game.meta.title {
|
||||
out_cue.add_ref(cue::CueItem::Title(title));
|
||||
}
|
||||
|
||||
if let Some(author) = game.meta.author {
|
||||
out_cue.add_ref(cue::CueItem::Performer(author));
|
||||
}
|
||||
|
||||
let mut tracks = game
|
||||
.track
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let mut v = v.clone();
|
||||
v.id = k.clone();
|
||||
v
|
||||
})
|
||||
.collect::<Vec<Track>>();
|
||||
|
||||
tracks.sort_by(|v1, v2| {
|
||||
if v1.id == game.meta.beginning {
|
||||
return std::cmp::Ordering::Less;
|
||||
}
|
||||
if v2.id == game.meta.beginning {
|
||||
return std::cmp::Ordering::Greater;
|
||||
}
|
||||
v1.id.cmp(&v2.id)
|
||||
});
|
||||
|
||||
let base_out_dir = std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(PathBuf::from(game_path).file_prefix().unwrap());
|
||||
|
||||
std::fs::create_dir_all(base_out_dir.clone()).unwrap();
|
||||
|
||||
let mut i = 0usize;
|
||||
for track in tracks.clone() {
|
||||
println!("Working on track {}...", track.id);
|
||||
let mut text = track.text;
|
||||
text += "\n\nYou can ";
|
||||
for option in track.options {
|
||||
text += &option.text;
|
||||
text += " by pressing the ";
|
||||
let mut i2 = -1isize;
|
||||
for track2 in &tracks {
|
||||
if track2.id == option.to {
|
||||
i2 += 1;
|
||||
break;
|
||||
}
|
||||
i2 += 1;
|
||||
}
|
||||
if i2 == -1 {
|
||||
panic!("Cannot find option reference {}!", option.to);
|
||||
}
|
||||
i2 += 1;
|
||||
|
||||
let offset = i2 - (i + 1) as isize;
|
||||
if offset.is_negative() {
|
||||
if offset == -1 {
|
||||
text += &format!("skip backward button 1 time.");
|
||||
} else {
|
||||
text += &format!("skip backward button {} times.", -offset - 1);
|
||||
}
|
||||
} else if offset.is_positive() {
|
||||
if offset == 1 {
|
||||
text += &format!("skip forward button 1 time.");
|
||||
} else {
|
||||
text += &format!("skip forward button {} times.", offset);
|
||||
}
|
||||
} else {
|
||||
text += &format!("skip backward button 1 time.");
|
||||
}
|
||||
text += "You can also ";
|
||||
}
|
||||
text = text.trim_end_matches("You can also ").to_string();
|
||||
std::fs::write(
|
||||
base_out_dir.join(track.id.clone() + ".mp3"),
|
||||
speak_text(
|
||||
client.clone(),
|
||||
text,
|
||||
track.voice.unwrap_or(game.meta.default_voice.clone()),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
.unwrap();
|
||||
std::process::Command::new("ffmpeg")
|
||||
.args(vec![
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=channel_layout=stereo:sample_rate=44100:duration=10",
|
||||
"-i",
|
||||
&base_out_dir
|
||||
.join(track.id.clone() + ".mp3")
|
||||
.to_string_lossy(),
|
||||
"-filter_complex",
|
||||
"[1:a] [0:a] concat=n=2:v=0:a=1",
|
||||
&base_out_dir
|
||||
.join(track.id.clone() + "_slience.mp3")
|
||||
.to_string_lossy(),
|
||||
])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
.wait()
|
||||
.unwrap();
|
||||
|
||||
std::fs::remove_file(base_out_dir.join(track.id.clone() + ".mp3")).unwrap();
|
||||
std::fs::rename(
|
||||
base_out_dir.join(track.id.clone() + "_slience.mp3"),
|
||||
base_out_dir.join(track.id.clone() + ".mp3"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
out_cue.add_ref(cue::CueItem::File {
|
||||
path: (track.id + ".mp3").into(),
|
||||
filetype: "MP3".to_string(),
|
||||
});
|
||||
out_cue.add_ref(cue::CueItem::Track {
|
||||
idx: i + 1,
|
||||
tracktype: "AUDIO".to_string(),
|
||||
});
|
||||
if let Some(title) = track.title {
|
||||
out_cue.add_ref(cue::CueItem::Title(title));
|
||||
}
|
||||
if let Some(author) = track.author {
|
||||
out_cue.add_ref(cue::CueItem::Performer(author));
|
||||
}
|
||||
out_cue.add_ref(cue::CueItem::Index {
|
||||
num: 1,
|
||||
timestamp: "00:00:00".to_string(),
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
|
||||
std::fs::write(
|
||||
base_out_dir.join(
|
||||
(PathBuf::from(args[1].clone())
|
||||
.file_prefix()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
+ ".cue")
|
||||
.to_string(),
|
||||
),
|
||||
out_cue.finish(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
BIN
test/start.mp3
Normal file
BIN
test/start.mp3
Normal file
Binary file not shown.
12
test/test.cue
Normal file
12
test/test.cue
Normal file
|
@ -0,0 +1,12 @@
|
|||
REM Created by CompactAdventure2
|
||||
TITLE "Test"
|
||||
PERFORMER "Arthur Beck"
|
||||
FILE "start.mp3" MP3
|
||||
TRACK 01 AUDIO
|
||||
INDEX 01 00:00:00
|
||||
FILE "track2.mp3" MP3
|
||||
TRACK 02 AUDIO
|
||||
INDEX 01 00:00:00
|
||||
FILE "track3.mp3" MP3
|
||||
TRACK 03 AUDIO
|
||||
INDEX 01 00:00:00
|
BIN
test/track2.mp3
Normal file
BIN
test/track2.mp3
Normal file
Binary file not shown.
BIN
test/track3.mp3
Normal file
BIN
test/track3.mp3
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue