Inital version

This commit is contained in:
Arthur Beck 2025-05-07 20:14:33 -05:00
parent da49e2223b
commit b8921858f2
Signed by: ArthurB
GPG key ID: CA200B389F0F6BC9
9 changed files with 293 additions and 32 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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
View 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"

View file

@ -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),

View file

@ -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

Binary file not shown.

12
test/test.cue Normal file
View 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

Binary file not shown.

BIN
test/track3.mp3 Normal file

Binary file not shown.