Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ca199a7d | ||
|
|
83a440d893 | ||
|
|
77b2a5f3cd |
@@ -20,9 +20,6 @@ jobs:
|
|||||||
- platform: windows-arm64
|
- platform: windows-arm64
|
||||||
os: windows-11-arm
|
os: windows-11-arm
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
- platform: macos-x64
|
|
||||||
os: macos-15-intel
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- platform: macos-arm64
|
- platform: macos-arm64
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
|
|||||||
Generated
+603
-65
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.0.0-alpha2"
|
version = "2.0.0-alpha3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let arch = match (os, arch) {
|
let arch = match (os, arch) {
|
||||||
// cargo-packager names the disk images `aarch64`/`x64`.
|
// cargo-packager names the disk images `aarch64`.
|
||||||
("macos", "aarch64") => "aarch64",
|
("macos", "aarch64") => "aarch64",
|
||||||
("macos", "x86_64") => "x64",
|
|
||||||
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
|
||||||
("linux", "aarch64") => "aarch64",
|
("linux", "aarch64") => "aarch64",
|
||||||
("linux", "x86_64") => "x86_64",
|
("linux", "x86_64") => "x86_64",
|
||||||
@@ -243,7 +242,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn picks_the_artifact_matching_os_and_architecture() {
|
fn picks_the_artifact_matching_os_and_architecture() {
|
||||||
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
|
||||||
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
|
assert_eq!(selected("macos", "x86_64"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
selected("linux", "aarch64"),
|
selected("linux", "aarch64"),
|
||||||
Some("coop-linux-aarch64.tar.gz")
|
Some("coop-linux-aarch64.tar.gz")
|
||||||
@@ -258,6 +257,7 @@ mod tests {
|
|||||||
fn has_no_target_for_windows_or_unknown_platforms() {
|
fn has_no_target_for_windows_or_unknown_platforms() {
|
||||||
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
assert_eq!(asset_filter_for("windows", "x86_64"), None);
|
||||||
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
|
||||||
|
assert_eq!(asset_filter_for("macos", "x86_64"), None);
|
||||||
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
assert_eq!(asset_filter_for("macos", "riscv64"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
gpui-kit = { workspace = true, features = ["speech"] }
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
@@ -14,6 +15,7 @@ chat = { path = "../chat" }
|
|||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gpui::Action;
|
use gpui::{Action, actions};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use settings::SignerKind;
|
use settings::SignerKind;
|
||||||
@@ -15,3 +15,6 @@ pub enum Command {
|
|||||||
Njump(PublicKey),
|
Njump(PublicKey),
|
||||||
Trace(EventId),
|
Trace(EventId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start or stop dictation in the focused composer
|
||||||
|
actions!(chat, [ToggleDictation]);
|
||||||
|
|||||||
+85
-25
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
@@ -95,6 +96,9 @@ pub struct ChatPanel {
|
|||||||
/// Dictation session for the chat input
|
/// Dictation session for the chat input
|
||||||
speech: Entity<SpeechState>,
|
speech: Entity<SpeechState>,
|
||||||
|
|
||||||
|
/// When the running dictation started, for the elapsed counter
|
||||||
|
speech_started: Option<instant::Instant>,
|
||||||
|
|
||||||
/// Subject input state
|
/// Subject input state
|
||||||
subject_input: Entity<InputState>,
|
subject_input: Entity<InputState>,
|
||||||
|
|
||||||
@@ -165,7 +169,17 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Define dictation state
|
// Define dictation state
|
||||||
let speech = cx.new(SpeechState::new);
|
let speech = cx.new(|cx| {
|
||||||
|
let state = SpeechState::new(cx)
|
||||||
|
.recognizer(speech::recognizer())
|
||||||
|
// Batch transcription of a long recording can take a while.
|
||||||
|
.stop_timeout(Duration::from_secs(180));
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let state = state.input(speech::input());
|
||||||
|
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
// Define subject input state
|
// Define subject input state
|
||||||
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
||||||
@@ -196,14 +210,25 @@ impl ChatPanel {
|
|||||||
// Insert the dictated text into the chat input
|
// Insert the dictated text into the chat input
|
||||||
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
|
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
SpeechEvent::Final(text) if !text.is_empty() => {
|
SpeechEvent::Started => {
|
||||||
this.input.update(cx, |input, cx| {
|
this.speech_started = Some(instant::Instant::now());
|
||||||
input.insert(text.clone(), window, cx);
|
cx.notify();
|
||||||
});
|
}
|
||||||
|
SpeechEvent::Final(text) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
this.input
|
||||||
|
.update(cx, |input, cx| input.insert(text.clone(), window, cx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SpeechEvent::Error(error) => {
|
SpeechEvent::Error(error) => {
|
||||||
|
this.speech_started = None;
|
||||||
window.push_notification(Notification::error(error.to_string()), cx);
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
}
|
}
|
||||||
|
SpeechEvent::Cancelled => {
|
||||||
|
this.speech_started = None;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -227,6 +252,7 @@ impl ChatPanel {
|
|||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
speech,
|
speech,
|
||||||
|
speech_started: None,
|
||||||
subject_input,
|
subject_input,
|
||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
@@ -856,6 +882,17 @@ impl ChatPanel {
|
|||||||
self.directory.read(cx).person(public_key, cx)
|
self.directory.read(cx).person(public_key, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_dictation(
|
||||||
|
&mut self,
|
||||||
|
_: &ToggleDictation,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.speech.update(cx, |speech, cx| {
|
||||||
|
speech.toggle(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
match command {
|
match command {
|
||||||
Command::Insert(content) => {
|
Command::Insert(content) => {
|
||||||
@@ -1695,7 +1732,12 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
fn render_config_menu(
|
||||||
|
&self,
|
||||||
|
disabled: bool,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let (backup, signer_kind) = self
|
let (backup, signer_kind) = self
|
||||||
.room
|
.room
|
||||||
.read_with(cx, |this, _cx| {
|
.read_with(cx, |this, _cx| {
|
||||||
@@ -1708,6 +1750,7 @@ impl ChatPanel {
|
|||||||
.icon(IconName::Settings2)
|
.icon(IconName::Settings2)
|
||||||
.tooltip("Configuration")
|
.tooltip("Configuration")
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.disabled(disabled)
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
let auto = matches!(signer_kind, SignerKind::Auto);
|
let auto = matches!(signer_kind, SignerKind::Auto);
|
||||||
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
||||||
@@ -1738,7 +1781,12 @@ impl ChatPanel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
|
fn render_emoji_menu(
|
||||||
|
&self,
|
||||||
|
disabled: bool,
|
||||||
|
_window: &Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let current_user = nostr.read(cx).current_user();
|
let current_user = nostr.read(cx).current_user();
|
||||||
|
|
||||||
@@ -1755,6 +1803,7 @@ impl ChatPanel {
|
|||||||
Button::new("emoji")
|
Button::new("emoji")
|
||||||
.icon(IconName::Emoji)
|
.icon(IconName::Emoji)
|
||||||
.ghost()
|
.ghost()
|
||||||
|
.disabled(disabled)
|
||||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||||
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
||||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||||
@@ -1828,11 +1877,12 @@ impl Render for ChatPanel {
|
|||||||
|
|
||||||
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
||||||
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
||||||
let capturing = self.speech.read(cx).status().is_capturing();
|
let dictating = self.speech.read(cx).status().is_active();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all(self.id.clone()))
|
.image_cache(retain_all(self.id.clone()))
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
|
.on_action(cx.listener(Self::toggle_dictation))
|
||||||
.size_full()
|
.size_full()
|
||||||
.when(*self.subject_bar.read(cx), |this| {
|
.when(*self.subject_bar.read(cx), |this| {
|
||||||
this.child(
|
this.child(
|
||||||
@@ -1909,42 +1959,52 @@ impl Render for ChatPanel {
|
|||||||
.icon(IconName::Paperclip)
|
.icon(IconName::Paperclip)
|
||||||
.tooltip("Upload media")
|
.tooltip("Upload media")
|
||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(
|
.when(dictating, |this| {
|
||||||
Textarea::new(&self.input)
|
this.child(
|
||||||
.appearance(false)
|
h_flex()
|
||||||
.small()
|
.h_8()
|
||||||
.min_h_8()
|
.flex_1()
|
||||||
.py_1()
|
.gap_2()
|
||||||
.flex_1(),
|
.pl_1()
|
||||||
)
|
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||||
|
.when_some(self.speech_started, |this, started| {
|
||||||
|
this.child(speech::elapsed(started, cx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
Textarea::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.small()
|
||||||
|
.min_h_8()
|
||||||
|
.py_1()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.items_end()
|
.items_end()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.pl_1()
|
.pl_1()
|
||||||
.when(capturing, |this| {
|
|
||||||
this.child(
|
|
||||||
SpeechWaveform::new(&self.speech).xsmall().w_12(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(SpeechButton::new(&self.speech)),
|
.child(SpeechButton::new(&self.speech)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.pl_1()
|
.pl_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(self.render_emoji_menu(window, cx))
|
.child(self.render_emoji_menu(dictating, window, cx))
|
||||||
.child(self.render_config_menu(window, cx))
|
.child(self.render_config_menu(dictating, window, cx))
|
||||||
.child(
|
.child(
|
||||||
Button::new("send")
|
Button::new("send")
|
||||||
.icon(IconName::PaperPlaneFill)
|
.icon(IconName::PaperPlaneFill)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.send_text_message(window, cx);
|
this.send_text_message(window, cx);
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ common = { path = "../common" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
gpui-kit = { workspace = true, features = ["speech"] }
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
+73
-20
@@ -1,11 +1,14 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render,
|
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
|
||||||
SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list,
|
||||||
|
px,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
@@ -315,6 +318,8 @@ pub struct GroupPanel {
|
|||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
/// Dictation session for the composer input
|
/// Dictation session for the composer input
|
||||||
speech: Entity<SpeechState>,
|
speech: Entity<SpeechState>,
|
||||||
|
/// When the running dictation started, for the elapsed counter
|
||||||
|
speech_started: Option<instant::Instant>,
|
||||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||||
attachments: Vec<Url>,
|
attachments: Vec<Url>,
|
||||||
/// Whether an upload is in flight.
|
/// Whether an upload is in flight.
|
||||||
@@ -341,7 +346,17 @@ impl GroupPanel {
|
|||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
let speech = cx.new(SpeechState::new);
|
let speech = cx.new(|cx| {
|
||||||
|
let state = SpeechState::new(cx)
|
||||||
|
.recognizer(speech::recognizer())
|
||||||
|
// Batch transcription of a long recording can take a while.
|
||||||
|
.stop_timeout(Duration::from_secs(180));
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let state = state.input(speech::input());
|
||||||
|
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
@@ -356,14 +371,26 @@ impl GroupPanel {
|
|||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
|
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
SpeechEvent::Final(text) if !text.is_empty() => {
|
SpeechEvent::Started => {
|
||||||
this.input.update(cx, |input, cx| {
|
this.speech_started = Some(instant::Instant::now());
|
||||||
input.insert(text.clone(), window, cx);
|
cx.notify();
|
||||||
});
|
}
|
||||||
|
SpeechEvent::Final(text) => {
|
||||||
|
this.speech_started = None;
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
this.input.update(cx, |input, cx| {
|
||||||
|
input.insert(text.clone(), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SpeechEvent::Error(error) => {
|
SpeechEvent::Error(error) => {
|
||||||
|
this.speech_started = None;
|
||||||
window.push_notification(Notification::error(error.to_string()), cx);
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
}
|
}
|
||||||
|
SpeechEvent::Cancelled => {
|
||||||
|
this.speech_started = None;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -405,6 +432,7 @@ impl GroupPanel {
|
|||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
input,
|
input,
|
||||||
speech,
|
speech,
|
||||||
|
speech_started: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
@@ -1037,6 +1065,17 @@ impl GroupPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_dictation(
|
||||||
|
&mut self,
|
||||||
|
_ev: &ToggleDictation,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.speech.update(cx, |speech, cx| {
|
||||||
|
speech.toggle(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let notice = self.group.upgrade().and_then(|group| {
|
let notice = self.group.upgrade().and_then(|group| {
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
@@ -1062,6 +1101,8 @@ impl GroupPanel {
|
|||||||
return self.render_notice(¬ice, cx);
|
return self.render_notice(¬ice, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dictating = self.speech.read(cx).status().is_active();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1080,23 +1121,34 @@ impl GroupPanel {
|
|||||||
.icon(ui::IconName::Paperclip)
|
.icon(ui::IconName::Paperclip)
|
||||||
.tooltip("Upload media")
|
.tooltip("Upload media")
|
||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.upload(window, cx);
|
this.upload(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(
|
.when(dictating, |this| {
|
||||||
Textarea::new(&self.input)
|
this.child(
|
||||||
.appearance(false)
|
h_flex()
|
||||||
.bg(cx.theme().input)
|
.h_8()
|
||||||
.small()
|
.flex_1()
|
||||||
.min_h_8()
|
.gap_2()
|
||||||
.py_1()
|
.child(SpeechWaveform::new(&self.speech).small().flex_1())
|
||||||
.flex_1(),
|
.when_some(self.speech_started, |this, started| {
|
||||||
)
|
this.child(speech::elapsed(started, cx))
|
||||||
.when(self.speech.read(cx).status().is_capturing(), |this| {
|
}),
|
||||||
this.child(SpeechWaveform::new(&self.speech).xsmall().w_12())
|
)
|
||||||
|
})
|
||||||
|
.when(!dictating, |this| {
|
||||||
|
this.child(
|
||||||
|
Textarea::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.bg(cx.theme().input)
|
||||||
|
.small()
|
||||||
|
.min_h_8()
|
||||||
|
.py_1()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.child(SpeechButton::new(&self.speech))
|
.child(SpeechButton::new(&self.speech))
|
||||||
.child(
|
.child(
|
||||||
@@ -1104,7 +1156,7 @@ impl GroupPanel {
|
|||||||
.icon(ui::IconName::PaperPlaneFill)
|
.icon(ui::IconName::PaperPlaneFill)
|
||||||
.tooltip("Send")
|
.tooltip("Send")
|
||||||
.ghost()
|
.ghost()
|
||||||
.disabled(self.uploading)
|
.disabled(dictating || self.uploading)
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.send(window, cx);
|
this.send(window, cx);
|
||||||
})),
|
})),
|
||||||
@@ -1302,6 +1354,7 @@ impl Render for GroupPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
|
.on_action(cx.listener(Self::toggle_dictation))
|
||||||
.child(self.render_encryption_notice(cx))
|
.child(self.render_encryption_notice(cx))
|
||||||
.child(self.render_banner(cx))
|
.child(self.render_banner(cx))
|
||||||
.child(self.render_pins(cx))
|
.child(self.render_pins(cx))
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "speech"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui-kit = { workspace = true, features = ["speech"] }
|
||||||
|
flume.workspace = true
|
||||||
|
instant.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
parakeet-rs = "=0.3.8"
|
||||||
|
ort = "=2.0.0-rc.13"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"blocking",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
js-sys = "0.3"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
web-sys = { version = "0.3", features = ["Window"] }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod model;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod parakeet;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod web;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
|
||||||
|
Styled as _, div,
|
||||||
|
};
|
||||||
|
use gpui_kit::component::ActiveTheme as _;
|
||||||
|
use gpui_kit::component::speech::SpeechRecognizer;
|
||||||
|
|
||||||
|
/// A ticking `M:SS` counter for the dictation in progress.
|
||||||
|
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.with_animation(
|
||||||
|
"dictation-elapsed",
|
||||||
|
Animation::new(Duration::from_secs(1))
|
||||||
|
.repeat()
|
||||||
|
.with_max_fps(1.),
|
||||||
|
move |counter, _| counter.child(format_elapsed(started)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(started: instant::Instant) -> String {
|
||||||
|
let seconds = started.elapsed().as_secs();
|
||||||
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recognizer backing the composer's microphone button.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn recognizer() -> impl SpeechRecognizer {
|
||||||
|
parakeet::ParakeetRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recognizer backing the composer's microphone button.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn recognizer() -> impl SpeechRecognizer {
|
||||||
|
web::WebSpeechRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio source backing the composer's microphone button on the web.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
|
||||||
|
web::SelfCapturedInput
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
|
||||||
|
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
|
||||||
|
"source": "nvidia/parakeet-tdt-0.6b-v3",
|
||||||
|
"license": "CC-BY-4.0",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "decoder_joint-model.int8.onnx",
|
||||||
|
"size": 18202004,
|
||||||
|
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "encoder-model.int8.onnx",
|
||||||
|
"size": 652183999,
|
||||||
|
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vocab.txt",
|
||||||
|
"size": 93939,
|
||||||
|
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// How the model is currently available.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Status {
|
||||||
|
NotInstalled,
|
||||||
|
Downloading { downloaded: u64, total: u64 },
|
||||||
|
Installed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
pub fn is_downloading(self) -> bool {
|
||||||
|
matches!(self, Status::Downloading { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Manifest {
|
||||||
|
repository: String,
|
||||||
|
revision: String,
|
||||||
|
files: Vec<Artifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Artifact {
|
||||||
|
name: String,
|
||||||
|
size: u64,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest() -> &'static Manifest {
|
||||||
|
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
|
||||||
|
|
||||||
|
MANIFEST.get_or_init(|| {
|
||||||
|
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory the model artifacts are downloaded into.
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
common::config_dir()
|
||||||
|
.join("speech")
|
||||||
|
.join("parakeet-tdt-0.6b-v3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total download size in bytes.
|
||||||
|
pub fn total_size() -> u64 {
|
||||||
|
manifest().files.iter().map(|file| file.size).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The revision of the installed model, if its artifacts are complete.
|
||||||
|
fn installed_revision() -> Option<String> {
|
||||||
|
let manifest = manifest();
|
||||||
|
let dir = dir();
|
||||||
|
|
||||||
|
let complete = manifest.files.iter().all(|file| {
|
||||||
|
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
|
||||||
|
});
|
||||||
|
|
||||||
|
if complete
|
||||||
|
&& fs::read_to_string(dir.join("verified"))
|
||||||
|
.is_ok_and(|revision| revision == manifest.revision)
|
||||||
|
{
|
||||||
|
Some(manifest.revision.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the model is ready to load.
|
||||||
|
pub fn installed() -> bool {
|
||||||
|
status() == Status::Installed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current status, reconciling the download state with the disk.
|
||||||
|
pub fn status() -> Status {
|
||||||
|
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if let Download::Running {
|
||||||
|
downloaded, total, ..
|
||||||
|
} = state.download
|
||||||
|
{
|
||||||
|
return Status::Downloading { downloaded, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
if installed_revision().is_some() {
|
||||||
|
Status::Installed
|
||||||
|
} else {
|
||||||
|
Status::NotInstalled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the last download failure, if it has not been reported yet.
|
||||||
|
pub fn take_error() -> Option<String> {
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.error.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start downloading the model, unless a download is already running.
|
||||||
|
pub fn start_download() {
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if matches!(state.download, Download::Running { .. }) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
state.error = None;
|
||||||
|
state.download = Download::Running {
|
||||||
|
downloaded: 0,
|
||||||
|
total: total_size(),
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = std::thread::Builder::new()
|
||||||
|
.name("speech-model-download".into())
|
||||||
|
.spawn(move || download(cancel))
|
||||||
|
{
|
||||||
|
state.download = Download::Idle;
|
||||||
|
state.error = Some(error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request cancellation of the running download.
|
||||||
|
pub fn cancel_download() {
|
||||||
|
let state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
|
if let Download::Running { cancel, .. } = &state.download {
|
||||||
|
cancel.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the downloaded model and any partial download.
|
||||||
|
pub fn delete() {
|
||||||
|
super::parakeet::unload();
|
||||||
|
cancel_download();
|
||||||
|
|
||||||
|
if let Err(error) = fs::remove_dir_all(dir())
|
||||||
|
&& error.kind() != io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
log::warn!("failed to remove the speech model: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download(cancel: Arc<AtomicBool>) {
|
||||||
|
let result = download_inner(&cancel);
|
||||||
|
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.download = Download::Idle;
|
||||||
|
|
||||||
|
// A cancelled download must not report a spurious failure.
|
||||||
|
if !cancelled(&cancel)
|
||||||
|
&& let Err(error) = result
|
||||||
|
{
|
||||||
|
state.error = Some(format!("{error:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_inner(cancel: &AtomicBool) -> Result<()> {
|
||||||
|
let manifest = manifest();
|
||||||
|
let dir = dir();
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
for file in &manifest.files {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let staging = dir.join(format!("{}.part", file.name));
|
||||||
|
let result = download_file(&client, manifest, file, &staging, cancel);
|
||||||
|
|
||||||
|
if result.is_err() || cancelled(cancel) {
|
||||||
|
let _unused = fs::remove_file(&staging);
|
||||||
|
result?;
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(dir.join("verified"), &manifest.revision)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_file(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
manifest: &Manifest,
|
||||||
|
file: &Artifact,
|
||||||
|
staging: &Path,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
|
use sha2::Digest as _;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://huggingface.co/{}/resolve/{}/{}",
|
||||||
|
manifest.repository, manifest.revision, file.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut response = client.get(url).send()?.error_for_status()?;
|
||||||
|
let mut out = fs::File::create(staging)?;
|
||||||
|
let mut hash = sha2::Sha256::new();
|
||||||
|
let mut size = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
bail!("Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk = [0u8; 64 * 1024];
|
||||||
|
let read = response.read(&mut chunk)?;
|
||||||
|
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
size += read as u64;
|
||||||
|
if size > file.size {
|
||||||
|
bail!("Unexpected model size");
|
||||||
|
}
|
||||||
|
|
||||||
|
hash.update(&chunk[..read]);
|
||||||
|
out.write_all(&chunk[..read])?;
|
||||||
|
|
||||||
|
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
if let Download::Running { downloaded, .. } = &mut state.download {
|
||||||
|
*downloaded += read as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
|
||||||
|
bail!("Model checksum verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sync_all()?;
|
||||||
|
fs::rename(staging, dir().join(&file.name))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancelled(cancel: &AtomicBool) -> bool {
|
||||||
|
cancel.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Download {
|
||||||
|
Idle,
|
||||||
|
Running {
|
||||||
|
downloaded: u64,
|
||||||
|
total: u64,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
download: Download,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> &'static Mutex<State> {
|
||||||
|
static STATE: Mutex<State> = Mutex::new(State {
|
||||||
|
download: Download::Idle,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
&STATE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_pins_v3_artifacts_with_exact_sizes() {
|
||||||
|
let manifest = manifest();
|
||||||
|
|
||||||
|
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
|
||||||
|
assert_eq!(manifest.revision.len(), 40);
|
||||||
|
assert_eq!(manifest.files.len(), 3);
|
||||||
|
assert_eq!(total_size(), 670_479_942);
|
||||||
|
assert!(
|
||||||
|
manifest
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use gpui::{App, Task};
|
||||||
|
use gpui_kit::component::speech::{
|
||||||
|
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
|
||||||
|
};
|
||||||
|
use parakeet_rs::{ParakeetTDT, Transcriber as _};
|
||||||
|
|
||||||
|
const MODEL_RATE: u32 = 16_000;
|
||||||
|
const MAX_SECONDS: usize = 120;
|
||||||
|
|
||||||
|
/// The on-device recognizer.
|
||||||
|
pub struct ParakeetRecognizer;
|
||||||
|
|
||||||
|
impl SpeechRecognizer for ParakeetRecognizer {
|
||||||
|
fn audio_format(&self) -> AudioFormat {
|
||||||
|
AudioFormat::new(MODEL_RATE, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self, _cx: &App) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
sink: SpeechSink,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
|
if super::model::installed() {
|
||||||
|
worker().send_or_log(Command::Warm(super::model::dir()));
|
||||||
|
} else if super::model::status().is_downloading() {
|
||||||
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
|
"The speech model is still downloading. Try again when it finishes."
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
return Err(SpeechError::recognizer(anyhow!(
|
||||||
|
"No speech model is installed. Download it from Settings."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session buffers everything it is pushed,
|
||||||
|
// so audio is accepted right away.
|
||||||
|
sink.ready(cx);
|
||||||
|
|
||||||
|
Ok(Box::new(ParakeetSession {
|
||||||
|
sink,
|
||||||
|
samples: Vec::new(),
|
||||||
|
_result: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParakeetSession {
|
||||||
|
sink: SpeechSink,
|
||||||
|
samples: Vec<f32>,
|
||||||
|
_result: Option<Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecognitionSession for ParakeetSession {
|
||||||
|
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
|
||||||
|
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
|
||||||
|
self.samples
|
||||||
|
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, cx: &mut App) {
|
||||||
|
let samples = std::mem::take(&mut self.samples);
|
||||||
|
let sink = self.sink.clone();
|
||||||
|
|
||||||
|
// Speech too short to transcribe ends the session at once.
|
||||||
|
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
|
||||||
|
if samples.len() < MODEL_RATE as usize / 5 || silent {
|
||||||
|
sink.finish(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded();
|
||||||
|
worker().send_or_log(Command::Transcribe { samples, sender });
|
||||||
|
|
||||||
|
self._result = Some(cx.spawn(async move |cx| {
|
||||||
|
let result = receiver.recv_async().await;
|
||||||
|
|
||||||
|
cx.update(|cx| match result {
|
||||||
|
Ok(Ok(text)) if !text.is_empty() => {
|
||||||
|
sink.phrase(text, cx);
|
||||||
|
sink.finish(cx);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => sink.finish(cx),
|
||||||
|
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
|
||||||
|
Err(_) => sink.error(
|
||||||
|
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
|
||||||
|
cx,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Command {
|
||||||
|
Warm(PathBuf),
|
||||||
|
Transcribe {
|
||||||
|
samples: Vec<f32>,
|
||||||
|
sender: flume::Sender<Result<String, anyhow::Error>>,
|
||||||
|
},
|
||||||
|
Unload,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to the single inference worker.
|
||||||
|
fn worker() -> &'static flume::Sender<Command> {
|
||||||
|
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
|
||||||
|
|
||||||
|
WORKER.get_or_init(|| {
|
||||||
|
let (sender, receiver) = flume::unbounded::<Command>();
|
||||||
|
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("speech-parakeet".into())
|
||||||
|
.spawn(move || run_worker(receiver));
|
||||||
|
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
log::error!("failed to spawn the speech worker: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
sender
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_worker(receiver: flume::Receiver<Command>) {
|
||||||
|
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
|
||||||
|
|
||||||
|
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
|
||||||
|
match command {
|
||||||
|
Command::Unload => {
|
||||||
|
cached = None;
|
||||||
|
}
|
||||||
|
Command::Warm(dir) => {
|
||||||
|
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
load(&mut cached, &dir).map(|_| ())
|
||||||
|
}));
|
||||||
|
|
||||||
|
match flatten_panic(loaded) {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(error)) | Err(error) => {
|
||||||
|
log::warn!("failed to load the speech model: {error:#}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Transcribe { samples, sender } => {
|
||||||
|
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
transcribe(&mut cached, samples)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let result = match flatten_panic(transcribed) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
cached = None;
|
||||||
|
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sender.send(result).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its result arrived");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<'a>(
|
||||||
|
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
|
||||||
|
dir: &PathBuf,
|
||||||
|
) -> Result<&'a mut ParakeetTDT> {
|
||||||
|
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
|
||||||
|
let model = ParakeetTDT::from_pretrained(dir, None)
|
||||||
|
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
|
||||||
|
*cached = Some((dir.clone(), model));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
|
||||||
|
let model = load(cached, &super::model::dir())?;
|
||||||
|
|
||||||
|
model
|
||||||
|
.transcribe_samples(samples, MODEL_RATE, 1, None)
|
||||||
|
.map(|result| result.text)
|
||||||
|
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
trait SendOrLog {
|
||||||
|
fn send_or_log(&self, command: Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendOrLog for flume::Sender<Command> {
|
||||||
|
fn send_or_log(&self, command: Command) {
|
||||||
|
if self.send(command).is_err() {
|
||||||
|
log::debug!("speech worker is gone; command dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire cached weights, e.g. after the model was deleted.
|
||||||
|
pub(crate) fn unload() {
|
||||||
|
worker().send_or_log(Command::Unload);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
|
||||||
|
result.map_err(|_| anyhow!("The speech worker panicked"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use gpui::App;
|
||||||
|
use gpui_kit::Subscription;
|
||||||
|
use gpui_kit::component::speech::{
|
||||||
|
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
|
||||||
|
SpeechSink,
|
||||||
|
};
|
||||||
|
use js_sys::{Array, Function, JsString, Reflect};
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::{JsCast as _, JsValue};
|
||||||
|
use web_sys::window;
|
||||||
|
|
||||||
|
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
|
||||||
|
pub struct WebSpeechRecognizer;
|
||||||
|
|
||||||
|
impl SpeechRecognizer for WebSpeechRecognizer {
|
||||||
|
fn audio_format(&self) -> AudioFormat {
|
||||||
|
AudioFormat::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self, _cx: &App) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
sink: SpeechSink,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||||
|
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
|
||||||
|
|
||||||
|
Ok(Box::new(recognition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SelfCapturedInput;
|
||||||
|
|
||||||
|
impl AudioInput for SelfCapturedInput {
|
||||||
|
fn start(
|
||||||
|
&self,
|
||||||
|
_format: AudioFormat,
|
||||||
|
_sink: AudioSink,
|
||||||
|
_cx: &mut App,
|
||||||
|
) -> Result<Subscription, SpeechError> {
|
||||||
|
Ok(Subscription::new(|| {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
Hypothesis(String),
|
||||||
|
Phrase(String),
|
||||||
|
Error(String),
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Recognition {
|
||||||
|
recognition: JsValue,
|
||||||
|
on_result: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
on_error: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
on_end: Option<Closure<dyn FnMut(JsValue)>>,
|
||||||
|
_task: gpui::Task<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recognition {
|
||||||
|
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
|
||||||
|
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
|
||||||
|
|
||||||
|
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_function())
|
||||||
|
.or_else(|| {
|
||||||
|
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_function())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
|
||||||
|
|
||||||
|
let recognition =
|
||||||
|
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
|
||||||
|
.map_err(|error| js_error("constructing the recognizer", error))?;
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded::<Event>();
|
||||||
|
let error_sender = sender.clone();
|
||||||
|
let end_sender = sender.clone();
|
||||||
|
|
||||||
|
let finalized = RefCell::new(0usize);
|
||||||
|
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
|
||||||
|
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
|
||||||
|
Reflect::get(value, &JsString::from(property)).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(results) = read(&event, "results") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let length = read(&results, "length")
|
||||||
|
.and_then(|length| length.as_f64())
|
||||||
|
.unwrap_or(0.0) as usize;
|
||||||
|
|
||||||
|
let mut finalized = finalized.borrow_mut();
|
||||||
|
let mut interim = String::new();
|
||||||
|
|
||||||
|
for index in *finalized..length {
|
||||||
|
let Some(result) = read(&results, &index.to_string()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(alternative) = read(&result, "0") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_final = read(&result, "isFinal")
|
||||||
|
.and_then(|final_value| final_value.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_final {
|
||||||
|
*finalized += 1;
|
||||||
|
if sender.send(Event::Phrase(text)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled mid-utterance");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interim.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled mid-utterance");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
|
||||||
|
let message = Reflect::get(&event, &JsString::from("error"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|error| error.as_string())
|
||||||
|
.unwrap_or_else(|| "unknown speech error".into());
|
||||||
|
|
||||||
|
if error_sender.send(Event::Error(message)).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its error arrived");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
|
||||||
|
if end_sender.send(Event::End).is_err() {
|
||||||
|
log::debug!("speech session was cancelled before its end arrived");
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(JsValue)>);
|
||||||
|
|
||||||
|
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
|
||||||
|
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
|
||||||
|
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
|
||||||
|
set_bool(&recognition, "continuous", true)?;
|
||||||
|
set_bool(&recognition, "interimResults", true)?;
|
||||||
|
|
||||||
|
call_method(&recognition, "start")?;
|
||||||
|
|
||||||
|
// The browser consumes audio as soon as it has permission
|
||||||
|
sink.ready(cx);
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |cx| {
|
||||||
|
while let Ok(event) = receiver.recv_async().await {
|
||||||
|
let done = matches!(event, Event::End);
|
||||||
|
|
||||||
|
// The speech state ignores sink calls once its session ended,
|
||||||
|
// so a late event after a cancel is harmless.
|
||||||
|
cx.update(|cx| match event {
|
||||||
|
Event::Hypothesis(text) => sink.hypothesis(text, cx),
|
||||||
|
Event::Phrase(text) => sink.phrase(text, cx),
|
||||||
|
Event::Error(message) => {
|
||||||
|
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
|
||||||
|
}
|
||||||
|
Event::End => sink.finish(cx),
|
||||||
|
});
|
||||||
|
|
||||||
|
if done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
recognition,
|
||||||
|
on_result: Some(on_result),
|
||||||
|
on_error: Some(on_error),
|
||||||
|
on_end: Some(on_end),
|
||||||
|
_task: task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
self.on_result = None;
|
||||||
|
self.on_error = None;
|
||||||
|
self.on_end = None;
|
||||||
|
|
||||||
|
let _ignored = call_method(&self.recognition, "abort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Recognition {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecognitionSession for Recognition {
|
||||||
|
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
|
||||||
|
// The browser recognizer captures the microphone itself.
|
||||||
|
// the state still pushes audio through its input, which is a no-op here.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, _cx: &mut App) {
|
||||||
|
// The end handler reports completion, so no result is fabricated here.
|
||||||
|
let _ignored = call_method(&self.recognition, "stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
|
||||||
|
Reflect::set(target, &JsString::from(property), callback)
|
||||||
|
.map_err(|error| js_error(property, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
|
||||||
|
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
|
||||||
|
.map_err(|error| js_error(property, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
|
||||||
|
let function = Reflect::get(target, &JsString::from(method))
|
||||||
|
.map_err(|error| js_error(method, error))?
|
||||||
|
.unchecked_into::<Function>();
|
||||||
|
|
||||||
|
function
|
||||||
|
.call0(target)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| js_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
|
||||||
|
let description = error
|
||||||
|
.as_string()
|
||||||
|
.or_else(|| {
|
||||||
|
Reflect::get(&error, &JsString::from("message"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|message| message.as_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown error".into());
|
||||||
|
|
||||||
|
anyhow!("{what} failed: {description}")
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ chat_ui = { path = "../chat_ui" }
|
|||||||
nip29 = { path = "../nip29" }
|
nip29 = { path = "../nip29" }
|
||||||
nip29_ui = { path = "../nip29_ui" }
|
nip29_ui = { path = "../nip29_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
speech = { path = "../speech" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
auto_update = { path = "../auto_update" }
|
auto_update = { path = "../auto_update" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui::PathPromptOptions;
|
use gpui::PathPromptOptions;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui::Task;
|
||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription,
|
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled,
|
||||||
Window, div,
|
Subscription, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
|
||||||
|
use gpui_kit::component::kbd::Kbd;
|
||||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use gpui_kit::component::progress::Progress;
|
||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use settings::{AppSettings, AppearanceMode};
|
use settings::{AppSettings, AppearanceMode};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use speech::model::{self as speech_model};
|
||||||
use ui::{settings_block, settings_row};
|
use ui::{settings_block, settings_row};
|
||||||
|
|
||||||
/// Bounds for the theme number inputs
|
/// Bounds for the theme number inputs
|
||||||
@@ -29,6 +40,8 @@ pub struct Preferences {
|
|||||||
font_size_input: Entity<InputState>,
|
font_size_input: Entity<InputState>,
|
||||||
radius_input: Entity<InputState>,
|
radius_input: Entity<InputState>,
|
||||||
radius_lg_input: Entity<InputState>,
|
radius_lg_input: Entity<InputState>,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
_speech_poll: Task<()>,
|
||||||
/// Keeps the control subscriptions alive while the dialog is open
|
/// Keeps the control subscriptions alive while the dialog is open
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -94,11 +107,40 @@ impl Preferences {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let timer = Duration::from_millis(500);
|
||||||
|
let mut last = speech_model::status();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(timer).await;
|
||||||
|
|
||||||
|
let status = speech_model::status();
|
||||||
|
let error = speech_model::take_error();
|
||||||
|
|
||||||
|
if status == last {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
last = status;
|
||||||
|
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
if let Some(error) = error {
|
||||||
|
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
file_input,
|
file_input,
|
||||||
font_size_input,
|
font_size_input,
|
||||||
radius_input,
|
radius_input,
|
||||||
radius_lg_input,
|
radius_lg_input,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
_speech_poll,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,10 +230,102 @@ impl Preferences {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_speech(
|
||||||
|
&mut self,
|
||||||
|
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let size = speech_model::total_size() / 1_000_000;
|
||||||
|
let status = speech_model::status();
|
||||||
|
|
||||||
|
let (description, control) = match &status {
|
||||||
|
speech_model::Status::NotInstalled => (
|
||||||
|
format!("{size} MB download"),
|
||||||
|
Button::new("download-model")
|
||||||
|
.label("Download")
|
||||||
|
.secondary()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::start_download();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
speech_model::Status::Downloading { downloaded, total } => (
|
||||||
|
format!(
|
||||||
|
"Downloading · {} of {} MB",
|
||||||
|
downloaded / 1_000_000,
|
||||||
|
total / 1_000_000
|
||||||
|
),
|
||||||
|
Button::new("cancel-download")
|
||||||
|
.label("Cancel")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::cancel_download();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
speech_model::Status::Installed => (
|
||||||
|
format!("Parakeet v3 · {size} MB"),
|
||||||
|
Button::new("remove-model")
|
||||||
|
.label("Remove")
|
||||||
|
.secondary()
|
||||||
|
.small()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
speech_model::delete();
|
||||||
|
cx.refresh_windows();
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut block = v_flex().w_full().gap_3().child(settings_row(
|
||||||
|
"Speech model",
|
||||||
|
description,
|
||||||
|
control,
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let speech_model::Status::Downloading { downloaded, total } = &status {
|
||||||
|
let (downloaded, total) = (*downloaded, *total);
|
||||||
|
|
||||||
|
block = block.child(
|
||||||
|
Progress::new("speech-model-download")
|
||||||
|
.value(downloaded as f32 / total.max(1) as f32 * 100.)
|
||||||
|
.small(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
block.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the keystroke actually bound to the dictation action at startup
|
||||||
|
fn render_dictation_shortcut(
|
||||||
|
&self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let control = match Kbd::global_binding_for_action(&ToggleDictation, window) {
|
||||||
|
Some(kbd) => kbd.into_any_element(),
|
||||||
|
None => div().into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings_row("Shortcut", "Press to start or stop dictation", control, cx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Preferences {
|
impl Render for Preferences {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
||||||
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
const WOT: &str = "Treat people followed by your contacts as known senders.";
|
||||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||||
@@ -374,6 +508,9 @@ impl Render for Preferences {
|
|||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
|
.child(self.render_speech(cx))
|
||||||
|
.child(self.render_dictation_shortcut(window, cx))
|
||||||
|
.child(Separator::horizontal())
|
||||||
.child(settings_block(
|
.child(settings_block(
|
||||||
"Media Upload Service",
|
"Media Upload Service",
|
||||||
MEDIA,
|
MEDIA,
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "2.0.0-alpha2"
|
version = "2.0.0-alpha3"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
@@ -38,6 +38,7 @@ ui = { path = "../crates/ui" }
|
|||||||
state = { path = "../crates/state" }
|
state = { path = "../crates/state" }
|
||||||
nip4e = { path = "../crates/nip4e" }
|
nip4e = { path = "../crates/nip4e" }
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
|
chat_ui = { path = "../crates/chat_ui" }
|
||||||
nip29 = { path = "../crates/nip29" }
|
nip29 = { path = "../crates/nip29" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
auto_update = { path = "../crates/auto_update" }
|
auto_update = { path = "../crates/auto_update" }
|
||||||
|
|||||||
+11
-4
@@ -1,5 +1,6 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use chat_ui::ToggleDictation;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
|
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
|
||||||
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
||||||
@@ -45,13 +46,19 @@ fn main() {
|
|||||||
// Register the `quit` function
|
// Register the `quit` function
|
||||||
cx.on_action(quit);
|
cx.on_action(quit);
|
||||||
|
|
||||||
// Register the `quit` function with CMD+Q (macOS)
|
// Register the `quit` function with CMD+Q (macOS) and dictation with CMD+D
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("cmd-q", Quit, None),
|
||||||
|
KeyBinding::new("cmd-d", ToggleDictation, None),
|
||||||
|
]);
|
||||||
|
|
||||||
// Register the `quit` function with Super+Q (others)
|
// Register the `quit` function with Super+Q (others) and dictation with Ctrl+D
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("super-q", Quit, None),
|
||||||
|
KeyBinding::new("ctrl-d", ToggleDictation, None),
|
||||||
|
]);
|
||||||
|
|
||||||
// Set menu items
|
// Set menu items
|
||||||
cx.set_menus(vec![Menu {
|
cx.set_menus(vec![Menu {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ channel = "1.98"
|
|||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
targets = [
|
targets = [
|
||||||
"x86_64-apple-darwin",
|
|
||||||
"aarch64-apple-darwin",
|
"aarch64-apple-darwin",
|
||||||
"x86_64-unknown-linux-gnu",
|
"x86_64-unknown-linux-gnu",
|
||||||
"x86_64-pc-windows-msvc",
|
"x86_64-pc-windows-msvc",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ui = { path = "../crates/ui" }
|
|||||||
state = { path = "../crates/state" }
|
state = { path = "../crates/state" }
|
||||||
nip4e = { path = "../crates/nip4e" }
|
nip4e = { path = "../crates/nip4e" }
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
|
chat_ui = { path = "../crates/chat_ui" }
|
||||||
nip29 = { path = "../crates/nip29" }
|
nip29 = { path = "../crates/nip29" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
person = { path = "../crates/person" }
|
person = { path = "../crates/person" }
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ pub fn run() -> Result<(), JsValue> {
|
|||||||
|
|
||||||
APPLICATION.with(|application| {
|
APPLICATION.with(|application| {
|
||||||
*application.borrow_mut() = Some(app.run_embedded(|cx| {
|
*application.borrow_mut() = Some(app.run_embedded(|cx| {
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("cmd-d", chat_ui::ToggleDictation, None),
|
||||||
|
KeyBinding::new("ctrl-d", chat_ui::ToggleDictation, None),
|
||||||
|
]);
|
||||||
|
|
||||||
gpui_kit::init(cx);
|
gpui_kit::init(cx);
|
||||||
|
|
||||||
load_embedded_fonts(cx);
|
load_embedded_fonts(cx);
|
||||||
|
|||||||
Reference in New Issue
Block a user