feat: add text to speech (#69)

Reviewed-on: #69
This commit was merged in pull request #69.
This commit is contained in:
2026-10-30 07:16:46 +00:00
parent af2a276b1b
commit 23d7017f25
11 changed files with 420 additions and 67 deletions
+54 -26
View File
@@ -26,8 +26,9 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use speech::speaker::Speaker;
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use ui::{Avatar, RenderedText, RevealText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -320,6 +321,12 @@ pub struct GroupPanel {
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<()>>>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
@@ -419,9 +426,24 @@ impl GroupPanel {
}
},
));
this.track_participants(cx);
});
let mut this = Self {
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
focus_handle: cx.focus_handle(),
group: group.downgrade(),
directory: Directory::new(cx),
@@ -433,19 +455,17 @@ impl GroupPanel {
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![],
tasks: smallvec![reveal_end_task],
_subscriptions: subscriptions,
};
this.track_participants(cx);
this
}
}
/// Track the keys rendered by this panel: members, message authors, and
/// activity subjects
/// Track the keys rendered by this panel.
fn track_participants(&mut self, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
@@ -1023,22 +1043,29 @@ impl GroupPanel {
let nostr = NostrRegistry::global(cx);
let directory = self.directory.clone();
let content = self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx);
let content = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == message.id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx),
};
let admin = nostr
.read(cx)
@@ -1059,6 +1086,7 @@ impl GroupPanel {
panel: cx.weak_entity(),
admin,
pinned,
speaking: self.speech_reveal.is_some(),
persons: self.directory.clone(),
},
cx,
+44 -6
View File
@@ -8,15 +8,17 @@ use gpui::{
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry;
use ui::{
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
react_menu,
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
group_reactions, react_menu,
};
use crate::GroupPanel;
@@ -26,6 +28,8 @@ pub(crate) struct Actions {
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
/// Whether a message is currently being spoken, disabling the speech buttons
pub speaking: bool,
pub persons: Entity<Directory>,
}
@@ -87,10 +91,44 @@ pub(crate) fn render(
let admin = actions.admin;
let pinned = actions.pinned;
let group = actions.group;
let panel = actions.panel;
let speech_panel = actions.panel;
let panel = speech_panel.clone();
let speech_text = message.preview();
let speech_id = message.id;
let speaking = actions.speaking;
let row = row.overlay(
MessageActions::new().speech(speaking, move |_event, _window, cx| {
_ = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) {
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
});
}),
);
// Reading the panel here would re-enter its ongoing update, so resolve
// the current user's custom emojis through the shared person directory.
let custom = nostr
.read(cx)
.current_user()