Compare commits

...
13 Commits
Author SHA1 Message Date
tdfischer 9c2023f6ca prediction: also split out the prediction task to another module for future growth 2026-06-03 19:30:23 +02:00
tdfischer 1e50c7222f tts: start splitting out tts into its own module, to grow later with more features 2026-06-03 19:21:00 +02:00
tdfischer f1e6684d9c transcription: split out transcription task into separate module 2026-06-03 19:15:37 +02:00
tdfischer 1e6f88ab87 main: make the audio file processing and tts speaking async 2026-06-02 22:50:07 +02:00
tdfischer 2e880ca552 main: use spooled temp files where possible, instead of "mic.wav" 2026-06-02 21:50:57 +02:00
tdfischer 2957be7b04 system-prompt: improve ability to stay in character 2026-06-02 11:40:20 +02:00
tdfischer a4f29a4d0d main: add lots of TODOs, clean up system messages when loading the session, and use espeak-ng directly which seems to have fewer audio glitches 2026-06-02 11:32:32 +02:00
tdfischer 9efa1f14b5 main: improve some UX aesthetics 2026-06-02 11:31:01 +02:00
tdfischer 6e29d88cf7 main: run the throbber on a wallclock based timer, so the VU meter updates don't cause crazy spinning 2026-06-02 11:30:21 +02:00
tdfischer 3904285391 main: bail at startup if we don't have an openai api key 2026-06-02 11:29:20 +02:00
tdfischer 5889d67dcb main: add some more useful /commands 2026-06-02 11:29:04 +02:00
tdfischer 326817733a main: implement the ability to replay eva utterances via list selection 2026-06-02 11:28:25 +02:00
tdfischer 5579b4dc64 code: implement audio transcription from mic audio 2026-06-02 11:25:22 +02:00
9 changed files with 797 additions and 104 deletions
Generated
+343
View File
@@ -158,6 +158,43 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "audio-core"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24"
[[package]]
name = "audioadapter"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91f87b70b051c5866680ad79f6743a42ccab264c009d1a71f4d33a3872ae60c8"
dependencies = [
"audio-core",
"num-traits",
]
[[package]]
name = "audioadapter-buffers"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9097d67933fb083d382ce980430afdb758aada60846010aee6be068c06cef0ca"
dependencies = [
"audioadapter",
"audioadapter-sample",
"num-traits",
]
[[package]]
name = "audioadapter-sample"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ab94f2bc04a14e1f49ee5f222f66460e8a1b51627bdfedf34eed394d747938"
dependencies = [
"audio-core",
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -233,6 +270,9 @@ name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
[[package]]
name = "bitmaps"
@@ -487,6 +527,31 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -878,17 +943,23 @@ dependencies = [
"crossterm",
"futures",
"futures-timer",
"hound",
"iref 4.0.0",
"jack",
"json-ld",
"oximedia-metering",
"ratatui",
"rc-writer",
"rdf-types",
"reqwest 0.13.4",
"resampler",
"schemars",
"scraper",
"serde",
"serde_json",
"sqlite",
"static-iref",
"tempfile",
"throbber-widgets-tui",
"tokio",
"tui-input",
@@ -1240,6 +1311,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
name = "heck"
@@ -1268,6 +1344,12 @@ dependencies = [
"digest",
]
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -1655,6 +1737,33 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jack"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7811b07bcac5dafabf814ab52c4b0ca9b7948aa1e279f572f03aa6544d47d27"
dependencies = [
"bitflags 2.11.1",
"jack-sys",
"lazy_static",
"libc",
"log",
]
[[package]]
name = "jack-sys"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab"
dependencies = [
"bitflags 1.3.2",
"lazy_static",
"libc",
"libloading",
"log",
"pkg-config",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -2019,6 +2128,22 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
dependencies = [
"cfg-if",
"winapi",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "line-clipping"
version = "0.3.7"
@@ -2277,6 +2402,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2321,6 +2455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -2414,6 +2549,86 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "oxifft"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0fe3b5f76266f8b860d18c307cacd467213b257dc3098641e19bc3ac17350b"
dependencies = [
"hashbrown 0.17.1",
"libm",
"num-complex",
"num-traits",
"oxifft-codegen",
"rayon",
"seahash",
"serde",
"serde_json",
"spin",
]
[[package]]
name = "oxifft-codegen"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b6627e29854ccb428fbf56e95c1970882096a53dae8954759ab09eb5d62f8d5"
dependencies = [
"oxifft-codegen-impl",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "oxifft-codegen-impl"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "660807d8bbd7453e595aca8523d6f6991dcba0ebd3509f2e5e0b98c1c348e920"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "oximedia-audio"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0408efebc08a1df406d8e56e4b47c19bbb2b69109f1298d943a8d6a435f57b9"
dependencies = [
"audioadapter",
"audioadapter-buffers",
"bytes",
"oxifft",
"oximedia-core",
"rubato",
"thiserror 2.0.18",
]
[[package]]
name = "oximedia-core"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb84c059565d2e515e8f7bf655dbda9719237cb4ec915de968bb599f9208efc1"
dependencies = [
"bitflags 2.11.1",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "oximedia-metering"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8832a54065b5023cd7d1881ef572d485a20d8a691c1528cac4cbfc5d054ea4"
dependencies = [
"oxifft",
"oximedia-audio",
"oximedia-core",
"rayon",
"thiserror 2.0.18",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2683,6 +2898,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
@@ -2943,6 +3167,32 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd1f6fba6db8161b6818f9061152e751b4d6030b39b561bbbb0153b36a6cfc5"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc-writer"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8049c74229f22d8cba889ee1d541b05da9c9668d8fe2011bb922250d0be148"
[[package]]
name = "rdf-types"
version = "0.22.5"
@@ -2961,6 +3211,15 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "realfft"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
dependencies = [
"rustfft",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3125,6 +3384,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "resampler"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28fdbea87ff02ebbfd904079d1e52138c0c7fbaa3aaddca4a1b9c7d3f85749f2"
[[package]]
name = "ring"
version = "0.17.14"
@@ -3139,6 +3404,22 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rubato"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce96ead1a91f7895704a9f08ea5947dfc8bd7c1f2936a22295b655ec67e5c6ef"
dependencies = [
"audioadapter",
"audioadapter-buffers",
"num-complex",
"num-integer",
"num-traits",
"realfft",
"visibility",
"windowfunctions",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
@@ -3160,6 +3441,20 @@ dependencies = [
"semver",
]
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -3336,6 +3631,12 @@ dependencies = [
"tendril",
]
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "secrecy"
version = "0.10.3"
@@ -3590,6 +3891,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1527984ca054dfca79333baec451042863f485fbee01b7bf6d911de915cac865"
[[package]]
name = "sqlite"
version = "0.37.0"
@@ -3705,6 +4012,12 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4199,6 +4512,16 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -4360,6 +4683,17 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "visibility"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "vtparse"
version = "0.6.2"
@@ -4658,6 +4992,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windowfunctions"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a"
dependencies = [
"num-traits",
]
[[package]]
name = "windows-core"
version = "0.62.2"
+6
View File
@@ -10,17 +10,23 @@ color-eyre = "0.6.5"
crossterm = { version = "0.29.0", features = ["event-stream"] }
futures = "0.3.32"
futures-timer = "3.0.4"
hound = "3.5.1"
iref = { version = "4.0.0", features = ["url", "serde"] }
jack = "0.13.5"
json-ld = { version = "0.21.4", features = ["reqwest", "serde"] }
oximedia-metering = "0.1.7"
ratatui = "0.30.0"
rc-writer = "1.1.10"
rdf-types = "0.22.5"
reqwest = "0.13.4"
resampler = "0.5.1"
schemars = "1.2.1"
scraper = "0.27.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sqlite = "0.37.0"
static-iref = "3.0.0"
tempfile = "3.27.0"
throbber-widgets-tui = "0.11.0"
tokio = { version = "1.52.3", features = ["full"] }
tui-input = "0.15.3"
+5
View File
@@ -0,0 +1,5 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AudioRecordRequest {
Start,
Finish
}
+247 -101
View File
@@ -1,19 +1,28 @@
use async_openai::{Client, config::OpenAIConfig, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse, FunctionObject}};
use async_openai::{types::chat::{ChatCompletionMessageToolCalls, CreateChatCompletionResponse}};
use chrono::{DateTime, Duration, Utc};
use futures_timer::Delay;
use schemars::{JsonSchema, schema_for};
use schemars::JsonSchema;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, List, ListDirection, ListItem, ListState, Paragraph, Wrap}};
use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, Gauge, List, ListDirection, ListItem, ListState, Paragraph, Wrap}};
use sqlite::OpenFlags;
use throbber_widgets_tui::{Throbber, ThrobberState};
use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}};
use tokio::sync::watch;
use tokio::{sync::{mpsc, watch}, time::Instant};
use tui_input::{Input, backend::crossterm::EventHandler};
use std::process::Command;
use futures::{StreamExt, future::FutureExt};
// TODO: We should have a separate 'state.json' file, which remembers jack connections, and the world time for the show to end. Then we only update the 'time remaining' field in the scene and only deal with relative durations inside the scene data
// TODO: We should be able to delete entries from the conversation, or at least go back and edit something I said.
// TODO: I want a "mark" command or keyboard shortcut, that inserts a marker into the log, so I know where to come back for the next speaking segment.
// TODO: If we insert text without speaking, this should be indicated visually somehow
// FIXME: The playlist ordering is always reversed when Eva talks about it? We also need some way to estimate where in the playlist we are currently, based on timekeeping and 'last played' state in mixxx
// TODO: We should be able to 'close' an episode, by having openai summarize the script into some description, which can be used automatically in the next episode.
// FIXME: It is unclear what would happen if we are live editing the save.json, have a typo, then reload. The file might get wiped without recovery.
// TODO: Would be nice to have some SFX integrated, with bleeps and calculation nosies or something periodically
/* Usage loop:
- Prompt user to select one of:
- Select response 1 (1)
@@ -34,9 +43,13 @@ use futures::{StreamExt, future::FutureExt};
use ratatui::prelude::*;
use crate::scene::{ConversationEntry, PlaylistEntry, Scene};
use crate::{events::AudioRecordRequest, scene::{ConversationEntry, PlaylistEntry, Scene}, tts::start_tts};
mod scene;
mod events;
mod transcription;
mod tts;
mod prediction;
#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
struct PossibleResponse {
@@ -73,28 +86,46 @@ struct App {
scene: Scene,
next_reply_options: Vec<PossibleResponse>,
reply_state: ListState,
conversation_state: ListState,
user_input: Input,
end_time: DateTime<Utc>,
throbber_state: ThrobberState,
prediction_request_sink: watch::Sender<Scene>,
is_requesting: bool
is_requesting: bool,
audio_level: f64,
recording_audio: bool,
audio_control_sink: watch::Sender<AudioRecordRequest>,
focus_state: FocusState,
tts_request_sink: mpsc::Sender<String>
}
#[derive(Debug)]
enum FocusState {
Conversation,
UserInput
}
impl App {
fn new(prediction_request_sink: watch::Sender<Scene>) -> Self {
fn new(prediction_request_sink: watch::Sender<Scene>, audio_control_sink: watch::Sender<AudioRecordRequest>, tts_request_sink: mpsc::Sender<String>) -> Self {
Self {
scene: Scene::default(),
next_reply_options: Vec::new(),
reply_state: ListState::default(),
conversation_state: ListState::default(),
user_input: Input::default(),
end_time: Utc::now() + Duration::hours(2),
throbber_state: ThrobberState::default(),
prediction_request_sink,
is_requesting: false
is_requesting: false,
audio_level: -60.,
recording_audio: false,
audio_control_sink,
focus_state: FocusState::UserInput,
tts_request_sink
}
}
fn draw_conversation(&self, frame: &mut Frame, area: Rect) {
fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) {
let items: Vec<Line> = self.scene.conversation.iter().rev().map(|entry| {
match entry {
ConversationEntry::User(text) => Line::from_iter([Span::from("Argee: ").style(ratatui::style::Color::Magenta), Span::from(text)]),
@@ -104,13 +135,23 @@ impl App {
ConversationEntry::SystemMessage(text) => Line::from_iter([text]).style(ratatui::style::Color::DarkGray)
}
}).collect();
frame.render_widget(List::new(items).block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation")).direction(ListDirection::BottomToTop), area);
// FIXME: We need to somehow make long list items wrap. https://github.com/ratatui/ratatui/issues/128#issuecomment-1613918499
// TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down
frame.render_stateful_widget(
List::new(items)
.block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)"))
.direction(ListDirection::BottomToTop)
.highlight_symbol("> ")
.highlight_style(style::Style::new().bold().fg(style::Color::Cyan)),
area,
&mut self.conversation_state
);
}
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
frame.render_stateful_widget(
List::new(self.next_reply_options.clone())
.block(Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Tab to use)"))
.block(Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)"))
.style(ratatui::style::Color::White)
.highlight_symbol("> ")
.highlight_style(style::Style::new().bold().fg(style::Color::Cyan)),
@@ -122,7 +163,7 @@ impl App {
fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) {
let width = area.width.max(3) - 3;
let scroll = self.user_input.visual_scroll(width as usize);
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input")).scroll((0, scroll as u16));
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input (Press 'Ctrl-X' to start/stop audio transcription)")).scroll((0, scroll as u16));
frame.render_widget(input, area);
let x = self.user_input.visual_cursor().max(scroll);
frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1));
@@ -156,6 +197,7 @@ impl App {
let status_line = Line::from_iter([
Span::from(format!("Episode {}", self.scene.direction.episode_number)).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
// FIXME: Looks weird with negative numbers, and it doesn't actually blink in the vscode terminal.
Span::from(format!("Time Remaining: {:0>2}:{:0>2}:{:0>2}", self.scene.direction.time_remaining.num_hours(), self.scene.direction.time_remaining.num_minutes() % 60, self.scene.direction.time_remaining.num_seconds() % 60)).style(time_style)
]);
frame.render_widget(status_line, area);
@@ -209,83 +251,188 @@ impl App {
let status_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Max(3), Constraint::Fill(1)])
.constraints([Constraint::Max(3), Constraint::Fill(2), Constraint::Fill(1)])
.split(layout[3]);
self.draw_user_input(frame, layout[2]);
self.draw_io_throbber(frame, status_layout[0]);
self.draw_status(frame, status_layout[1]);
self.draw_volume(frame, status_layout[2]);
}
fn insert_selected_prompt(&mut self) {
fn draw_volume(&self, frame: &mut Frame, area: Rect) {
const NOISE_FLOOR: f64 = 50.;
let vu_pct = 1.0 - (self.audio_level.abs().min(NOISE_FLOOR) / NOISE_FLOOR);
let volume_color = if self.recording_audio {
if vu_pct >= 0.85 {
style::Color::Red
} else if vu_pct >= 0.60 {
style::Color::Yellow
} else {
style::Color::LightGreen
}
} else {
style::Color::Gray
};
let gauge = Gauge::default()
.ratio(vu_pct)
.use_unicode(true)
.gauge_style(volume_color)
.label(format!("{:.01}dB", self.audio_level));
frame.render_widget(gauge, area);
}
async fn insert_selected_prompt(&mut self) {
let selected = self.next_reply_options[self.reply_state.selected().unwrap()].clone();
if let Some(direction) = &selected.stage_direction {
self.scene.insert_conversation(ConversationEntry::StageDirection(direction.clone()));
}
self.scene.insert_conversation(ConversationEntry::Eva(selected.text.clone()));
self.save();
self.speak(&selected.text.as_str());
self.speak(selected.text.clone()).await;
self.regenerate_responses();
}
async fn on_event(&mut self, evt: event::Event) {
if let Some(key) = evt.as_key_press_event() {
match key.code {
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.regenerate_responses(),
KeyCode::Down => self.reply_state.select_next(),
KeyCode::Up => self.reply_state.select_previous(),
KeyCode::Tab => {
self.insert_selected_prompt();
},
KeyCode::Enter => {
let next_msg = self.user_input.value_and_reset();
if next_msg.trim().is_empty() {
self.insert_selected_prompt();
} else {
if next_msg.starts_with("/") {
let mut parts = next_msg.splitn(2, " ");
let command = parts.next().unwrap();
let arg = parts.next().unwrap_or("");
match command {
"/bandcamp" => {
self.add_bandcamp_artifact(arg).await;
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Added Bandcamp artifact from {}", arg)));
},
"/episode" => {
if let Ok(episode_number) = arg.trim().parse::<u32>() {
self.scene.direction.episode_number = episode_number;
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated episode number: {}", self.scene.direction.episode_number)));
self.reload_mixxx_playlist();
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid episode number format. Use /episode [number]".into()));
}
},
"/reset" => {
self.scene = Scene::default();
return;
},
"/narrative" => {
self.scene.direction.narrative = arg.to_string();
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated stage direction: {}", self.scene.direction.narrative)));
},
_ => {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Unknown command. Available commands: /bandcamp [url], /episode [number], /narrative [text], /reset".into()));
return;
}
match self.focus_state {
FocusState::Conversation => {
match key.code {
KeyCode::Tab => {
self.focus_state = FocusState::UserInput;
self.conversation_state.select(None);
},
KeyCode::PageUp => self.conversation_state.scroll_down_by(5),
KeyCode::PageDown => self.conversation_state.scroll_up_by(5),
KeyCode::Home => self.conversation_state.select_last(),
KeyCode::End => self.conversation_state.select_first(),
KeyCode::Down => self.conversation_state.select_previous(),
KeyCode::Up => self.conversation_state.select_next(),
KeyCode::Enter => {
let row_num = self.conversation_state.selected().unwrap();
if let ConversationEntry::Eva(text) = &self.scene.conversation[self.scene.conversation.len() - 1 - row_num] {
self.speak(text.clone()).await;
self.focus_state = FocusState::UserInput;
self.conversation_state.select(None);
}
} else {
self.scene.insert_conversation(ConversationEntry::User(next_msg));
}
self.save();
self.regenerate_responses();
},
_ => ()
}
},
_ => {self.user_input.handle_event(&evt);},
FocusState::UserInput => {
match key.code {
KeyCode::Tab => {
self.focus_state = FocusState::Conversation;
self.conversation_state.select_first();
},
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.regenerate_responses(),
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.recording_audio {
self.recording_audio = false;
self.audio_control_sink.send_replace(AudioRecordRequest::Finish);
self.is_requesting = true;
} else {
self.recording_audio = true;
self.audio_control_sink.send_replace(AudioRecordRequest::Start);
}
},
KeyCode::Down => self.reply_state.select_next(),
KeyCode::Up => self.reply_state.select_previous(),
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.insert_selected_prompt().await;
},
KeyCode::Enter => {
let next_msg = self.user_input.value_and_reset();
if next_msg.trim().is_empty() {
self.insert_selected_prompt().await;
} else {
if next_msg.starts_with("/") {
let mut parts = next_msg.splitn(2, " ");
let command = parts.next().unwrap();
let arg = parts.next().unwrap_or("");
match command {
"/bandcamp" => {
self.add_bandcamp_artifact(arg).await;
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Added Bandcamp artifact from {}", arg)));
self.scene.insert_conversation(ConversationEntry::ShipComputer(format!("Incoming transmission.")));
},
"/episode" => {
if let Ok(episode_number) = arg.trim().parse::<u32>() {
self.scene.direction.episode_number = episode_number;
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated episode number: {}", self.scene.direction.episode_number)));
self.reload_mixxx_playlist();
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid episode number format. Use /episode [number]".into()));
}
},
"/reload" => {
self.load();
self.reload_mixxx_playlist();
},
"/timer" => {
if let Ok(minutes) = arg.trim().parse::<i64>() {
self.end_time = Utc::now() + Duration::minutes(minutes);
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Set timer for {} minutes.", minutes)));
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid timer format. Use /timer [minutes]".into()));
}
}
"/clear" => {
match arg.trim() {
"playlist" => {
self.scene.direction.current_playlist.clear();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared current playlist.".into()));
return;
},
"artifacts" => {
self.scene.direction.artifacts.clear();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared artifacts.".into()));
return;
},
"all" => {
self.scene = Scene::default();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared all data.".into()));
},
"conversation" => {
self.scene.conversation.clear();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared conversation.".into()));
},
_ => {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Unknown clear command. Use /clear [playlist|artifacts|all]".into()));
}
}
return;
},
"/narrative" => {
self.scene.direction.narrative = arg.to_string();
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated stage direction: {}", self.scene.direction.narrative)));
},
"/event" => {
self.scene.insert_conversation(ConversationEntry::StageDirection(arg.to_string()));
}
_ => {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Unknown command. Available commands: /bandcamp [url], /episode [number], /narrative [text], /reset".into()));
return;
}
}
} else {
self.scene.insert_conversation(ConversationEntry::User(next_msg));
}
self.save();
self.regenerate_responses();
}
},
_ => {self.user_input.handle_event(&evt);},
}
}
}
}
}
async fn add_bandcamp_artifact(&mut self, url: &str) {
// FIXME: This can crash if the page doesn't load properly, or if the structure of the Bandcamp page changes. We should add some error handling here.
let body = reqwest::get(url).await.unwrap().text().await.unwrap();
let fragment = Html::parse_document(&body);
let selector = Selector::parse("script[type=\"application/ld+json\"]").unwrap();
@@ -302,6 +449,8 @@ impl App {
if let Ok(save_data) = std::fs::read_to_string("save.json") {
if let Ok(scene) = serde_json::from_str(&save_data) {
self.scene = scene;
// FIXME: These should get wiped out when we save as well, or even better, be completely excluded via a custom serde implementation.
self.scene.conversation.retain(|line| { if let ConversationEntry::SystemMessage(_) = line { false } else { true }});
self.scene.insert_conversation(ConversationEntry::SystemMessage("Loaded stored session.".into()));
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Failed to load saved session!".into()));
@@ -309,8 +458,8 @@ impl App {
}
}
fn speak(&mut self, text: &str) {
Command::new("spd-say").arg("-y").arg("English (America)+Linda").arg(text).spawn().unwrap().wait().unwrap();
async fn speak(&mut self, text: String) {
self.tts_request_sink.send(text).await.unwrap();
}
fn regenerate_responses(&mut self) {
@@ -320,6 +469,7 @@ impl App {
}
fn reload_mixxx_playlist(&mut self) {
// TODO: Should have some status message which states how many tracks are in the playlist
self.scene.direction.current_playlist.clear();
let connection = sqlite::Connection::open_thread_safe_with_flags("mixxxdb.sqlite", OpenFlags::new().with_read_only()).unwrap();
let query = "SELECT id FROM Playlists WHERE name = ? ORDER BY id DESC LIMIT 1";
@@ -344,7 +494,7 @@ impl App {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Mixxx playlist reloaded.".into()));
}
fn on_response(&mut self, response: CreateChatCompletionResponse) {
fn on_response(&mut self, response: &CreateChatCompletionResponse) {
self.is_requesting = false;
if let Some(calls) = &response.choices[0].message.tool_calls {
for call in calls {
@@ -376,48 +526,32 @@ impl App {
#[tokio::main]
async fn main() {
color_eyre::install().unwrap();
if option_env!("OPENAI_API_KEY").is_none() {
eprintln!("Error: OPENAI_API_KEY environment variable not set. The application will not function without it.");
return;
}
let mut terminal: Terminal<CrosstermBackend<std::io::Stdout>> = ratatui::init();
let (prediction_in, mut prediction_out) = tokio::sync::watch::channel(None);
let (prediction_request_in, mut prediction_request_out) = tokio::sync::watch::channel(Scene::default());
let (sys_message_sender, mut sys_message_receiver) = tokio::sync::mpsc::channel(5);
let tts_request_sender = start_tts().await;
let (prediction_request_in, mut prediction_out) = prediction::start_prediction().await;
let (mut audio_state_receiver, audio_control_in, mut transcription_out) = transcription::start_transcription(sys_message_sender).await;
tokio::spawn(async move {
let client: Client<OpenAIConfig> = Client::default();
loop {
if let Ok(_) = prediction_request_out.changed().await {
let request = prediction_request_out.borrow().clone();
let chat_request = CreateChatCompletionRequest {
/*tools: Some(vec![
ChatCompletionTools::Function(
ChatCompletionTool {
function: FunctionObject {
name: "log_stage_event".into(),
description: Some("Log an event in the stage direction.".into()),
parameters: Some(schema_for!(StageEventArgs).into()),
..Default::default()
}
}
)
]),*/
..request.into()
};
let response = client.chat().create(chat_request).await.unwrap();
prediction_in.send(Some(response)).unwrap();
} else {
return;
}
}
});
let mut app = App::new(prediction_request_in);
let mut app = App::new(prediction_request_in, audio_control_in, tts_request_sender);
app.load();
let mut events = EventStream::new();
let mut last_tick = Instant::now();
loop {
terminal.draw(|frame| { app.draw(frame)}).unwrap();
app.throbber_state.calc_next();
if last_tick.elapsed() >= std::time::Duration::from_millis(100) {
last_tick = Instant::now();
app.throbber_state.calc_next();
}
app.scene.direction.time_remaining = app.end_time.signed_duration_since(Utc::now());
terminal.draw(|frame| { app.draw(frame)}).unwrap();
let delay = Delay::new(std::time::Duration::from_millis(60)).fuse();
let event = events.next().fuse();
@@ -425,8 +559,20 @@ async fn main() {
tokio::select! {
_ = delay => (),
_ = prediction_out.changed() => {
app.on_response(prediction_out.borrow().clone().unwrap());
app.on_response(prediction_out.borrow_and_update().as_ref().unwrap());
},
_ = audio_state_receiver.changed() => {
app.audio_level = *audio_state_receiver.borrow_and_update();
},
maybe_message = sys_message_receiver.recv() => {
if let Some(message) = maybe_message {
app.scene.insert_conversation(ConversationEntry::SystemMessage(message));
}
},
maybe_transcription = transcription_out.recv() => {
app.scene.insert_conversation(ConversationEntry::User(maybe_transcription.unwrap()));
app.regenerate_responses();
}
maybe_event = event => {
match maybe_event {
Some(Ok(event)) => {
+38
View File
@@ -0,0 +1,38 @@
use async_openai::{Client, config::OpenAIConfig, types::chat::{CreateChatCompletionRequest, CreateChatCompletionResponse}};
use crate::scene::Scene;
pub async fn start_prediction() -> (tokio::sync::watch::Sender<Scene>, tokio::sync::watch::Receiver<Option<CreateChatCompletionResponse>>) {
let (prediction_in, prediction_out) = tokio::sync::watch::channel(None);
let (prediction_request_in, mut prediction_request_out) = tokio::sync::watch::channel(Scene::default());
tokio::spawn(async move {
let client: Client<OpenAIConfig> = Client::default();
loop {
if let Ok(_) = prediction_request_out.changed().await {
let request = prediction_request_out.borrow_and_update().clone();
let chat_request = CreateChatCompletionRequest {
/*tools: Some(vec![
ChatCompletionTools::Function(
ChatCompletionTool {
function: FunctionObject {
name: "log_stage_event".into(),
description: Some("Log an event in the stage direction.".into()),
parameters: Some(schema_for!(StageEventArgs).into()),
..Default::default()
}
}
)
]),*/
..request.into()
};
let response = client.chat().create(chat_request).await.unwrap();
prediction_in.send(Some(response)).unwrap();
} else {
return;
}
}
});
(prediction_request_in, prediction_out)
}
-2
View File
@@ -1,10 +1,8 @@
use async_openai::types::chat::*;
use chrono::Duration;
use crossterm::event::MediaKeyCode::Play;
use schemars::schema_for;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlite::OpenFlags;
use crate::GeneratedResponses;
+3 -1
View File
@@ -35,12 +35,14 @@ In a subsequent system prompt, you will be given the currrent 'stage direction'
The stage direction is provided as structured JSON. There may be additional data fields for semantic context that should be incorporated into the roleplaying setting.
A list of artifacts that will be encountered during the episode are provided as blobs of json+ld metadata.
Additionally, the current playlist of the radio show can be found as an array of track data.
Your response will be used verbatim to generate speach using a text-to-speech engine, meaning you should not include any tone indicators or other formatting.
This also means that your responses must not refer to any "lore", or "show", these instructions, or anything else out of character.
All responses should remain in character at all times, as if you were actually an AI inhabiting a spaceship.
# Output
You will be required to give at least three responses to each prompt, each with a different tone, up to a maximum of 8 responses.
The first response should be in a neutral tone, the others can be chosen freely. There should always be one response that is exactly one short sentence, and one response which is somewhat longer.
One response should reference or otherwise allow Argee to use to develop lore for the show and the characters.
If Argee asks you to elaborate in response to your output, you may provide more information, but only if directly asked.
Your response will be used verbatim to generate speach using a text-to-speech engine, meaning you should not include any tone indicators or other formatting.
Each possible response may optionally include stage direction that is incorporated into the scene prior to Eva speaking. This stage direction may be as verbose as required, but it must describe what is happening.
For example, if Argee asks eva to "start recording this artifact", the stage direction should explicitly note that a recording has started.
+139
View File
@@ -0,0 +1,139 @@
use std::{io::Read, sync::{Arc, Mutex}};
use async_openai::{Client, config::OpenAIConfig, types::{InputSource, audio::{AudioInput, CreateTranscriptionRequest}}};
use jack::{AudioIn, ClientOptions};
use oximedia_metering::vu_meter::VuMeter;
use tempfile::SpooledData;
use tokio::sync::{mpsc, watch};
use crate::events::AudioRecordRequest;
struct RcFile<T>(Arc<Mutex<T>>);
impl<T: std::io::Write> std::io::Write for RcFile<T> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.0.lock().unwrap().flush()
}
}
impl<T: std::io::Seek> std::io::Seek for RcFile<T> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.0.lock().unwrap().seek(pos)
}
}
pub async fn start_transcription(messages: mpsc::Sender<String>) -> (watch::Receiver<f64>, watch::Sender<AudioRecordRequest>, mpsc::Receiver<String>) {
let (audio_sink, mut audio_src) = mpsc::channel(32);
let (audio_state_sender, audio_state_receiver) = watch::channel(0.);
let (audio_control_in, mut audio_control_out) = watch::channel(AudioRecordRequest::Finish);
let (transcription_in, transcription_out) = mpsc::channel(1);
let rate = start_audio_input(&messages, audio_sink).await;
tokio::spawn(async move {
let spec = hound::WavSpec {
channels: 1,
sample_rate: rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int
};
let mut meter = VuMeter::new(rate.into(), 1, None);
let spool_size = 16 * (rate as usize) * 10;// 10 seconds of audio
let mut writer = None;
let mut outfile = None;
let client: Client<OpenAIConfig> = Client::default();
loop {
tokio::select! {
_ = audio_control_out.changed() => {
let audio_event = *audio_control_out.borrow_and_update();
match audio_event {
AudioRecordRequest::Start => {
outfile = Some(Arc::new(Mutex::new(tempfile::spooled_tempfile(spool_size))));
writer = Some(hound::WavWriter::new(RcFile(outfile.as_ref().unwrap().clone()), spec).unwrap());
},
AudioRecordRequest::Finish => {
writer = None;
let final_audio = outfile.take().unwrap();
let bytes = match Arc::into_inner(final_audio).unwrap().into_inner().unwrap().into_inner() {
SpooledData::OnDisk(mut file) => {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
bytes.into()
},
SpooledData::InMemory(cursor) => cursor.into_inner().into(),
};
let c = client.clone();
let t = transcription_in.clone();
tokio::spawn(async move {
let response = c.audio().transcription().create(CreateTranscriptionRequest {
file: AudioInput { source: InputSource::Bytes { filename: "transcription.wav".into(), bytes } },
model: "gpt-4o-mini-transcribe".into(),
..Default::default()
}).await.unwrap();
t.send(response.text).await.unwrap();
});
}
}
},
maybe_audio_packet = audio_src.recv() => {
let buf = maybe_audio_packet.unwrap();
meter.process_interleaved(buf.as_slice());
if let Some(w) = writer.as_mut() {
for sample in buf.iter().copied() {
let sample_i16 = (sample * 32768.0)
.clamp(i16::MIN as f32, i16::MAX as f32)
as i16;
w.write_sample(sample_i16).unwrap();
}
w.flush().unwrap();
}
audio_state_sender.send_if_modified(|v| {
let next_vu = meter.channel_vu(0).unwrap();
if *v != next_vu {
*v = next_vu;
true
} else {
false
}
});
}
};
}
});
(audio_state_receiver, audio_control_in, transcription_out)
}
async fn start_audio_input(messages: &mpsc::Sender<String>, audio_sink: mpsc::Sender<Vec<f32>>) -> u32 {
let (client, _status) = jack::Client::new("Eva-Cohost", ClientOptions::default() | ClientOptions::SESSION_ID).unwrap();
let port = client.register_port("microphone-in", AudioIn::default()).unwrap();
let rate = client.sample_rate();
if let Ok(_) = client.connect_ports_by_name("mixxx-mic-1:capture_MONO", port.name().unwrap().as_str()) {
messages.send("Connected to audio.".into()).await.unwrap();
} else {
messages.send("Failed to reconnect to audio.".into()).await.unwrap();
}
let handler = jack::contrib::ClosureProcessHandler::new(move |_client, scope| {
if port.connected_count().unwrap() > 0 {
let buf: Vec<_> = port.as_slice(scope).iter().copied().collect();
audio_sink.blocking_send(buf).unwrap();
}
jack::Control::Continue
});
std::mem::forget(client.activate_async((), handler).unwrap());
rate
}
+16
View File
@@ -0,0 +1,16 @@
use std::process::Command;
pub async fn start_tts() -> tokio::sync::mpsc::Sender<String> {
let (tts_request_sender, mut tts_request_receiver) = tokio::sync::mpsc::channel(3);
// Set up the TTS task
tokio::spawn(async move {
while let Some(text) = tts_request_receiver.recv().await {
// TODO: We should also have espeak pipe out to stdout, then we can apply some audio effects and write to our own jack port.
Command::new("espeak-ng").arg("-v").arg("en-us+f3").arg(text).spawn().unwrap().wait().unwrap();
}
});
tts_request_sender
}