diff --git a/src/main.rs b/src/main.rs index 7fa7df9..fe735dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,32 @@ -use async_openai::{Client, config::OpenAIConfig, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse, FunctionObject}}; +use async_openai::{Client, config::OpenAIConfig, types::{InputSource, audio::{AudioInput, CreateTranscriptionRequest}, chat::{ChatCompletionMessageToolCalls, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse, FunctionObject}}}; use chrono::{DateTime, Duration, Utc}; use futures_timer::Delay; +use jack::{AudioIn, ClientOptions}; +use oximedia_metering::vu_meter::VuMeter; use schemars::{JsonSchema, schema_for}; 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 serde_json::Value; use sqlite::OpenFlags; use throbber_widgets_tui::{Throbber, ThrobberState}; use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}}; -use tokio::sync::watch; +use tokio::{sync::watch, time::Instant}; use tui_input::{Input, backend::crossterm::EventHandler}; -use std::process::Command; +use std::{io::Read, 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) @@ -73,6 +86,7 @@ struct App { scene: Scene, next_reply_options: Vec, reply_state: ListState, + conversation_state: ListState, user_input: Input, end_time: DateTime, throbber_state: ThrobberState, @@ -181,6 +195,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); @@ -415,6 +430,7 @@ impl App { } 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(); @@ -431,6 +447,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())); @@ -439,7 +457,9 @@ impl App { } fn speak(&mut self, text: &str) { - Command::new("spd-say").arg("-y").arg("English (America)+Linda").arg(text).spawn().unwrap().wait().unwrap(); + // FIXME: Utterances should be handled in another task, so the UI doesn't lock up while waiting for speech to finish + // 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(); } fn regenerate_responses(&mut self) { @@ -449,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";