mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(release): sync release tags during preflight (#780)
This commit is contained in:
@@ -30,20 +30,22 @@ fn main() {
|
||||
let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
|
||||
|
||||
let t0 = Instant::now();
|
||||
let mut cfg = OfflineTtsConfig::default();
|
||||
cfg.model = OfflineTtsModelConfig {
|
||||
pocket: OfflineTtsPocketModelConfig {
|
||||
lm_main: Some(p("lm_main.int8.onnx")),
|
||||
lm_flow: Some(p("lm_flow.int8.onnx")),
|
||||
encoder: Some(p("encoder.onnx")),
|
||||
decoder: Some(p("decoder.int8.onnx")),
|
||||
text_conditioner: Some(p("text_conditioner.onnx")),
|
||||
vocab_json: Some(p("vocab.json")),
|
||||
token_scores_json: Some(p("token_scores.json")),
|
||||
voice_embedding_cache_capacity: 16,
|
||||
let cfg = OfflineTtsConfig {
|
||||
model: OfflineTtsModelConfig {
|
||||
pocket: OfflineTtsPocketModelConfig {
|
||||
lm_main: Some(p("lm_main.int8.onnx")),
|
||||
lm_flow: Some(p("lm_flow.int8.onnx")),
|
||||
encoder: Some(p("encoder.onnx")),
|
||||
decoder: Some(p("decoder.int8.onnx")),
|
||||
text_conditioner: Some(p("text_conditioner.onnx")),
|
||||
vocab_json: Some(p("vocab.json")),
|
||||
token_scores_json: Some(p("token_scores.json")),
|
||||
voice_embedding_cache_capacity: 16,
|
||||
},
|
||||
num_threads: 1,
|
||||
debug: false,
|
||||
..Default::default()
|
||||
},
|
||||
num_threads: 1,
|
||||
debug: false,
|
||||
..Default::default()
|
||||
};
|
||||
let engine = OfflineTts::create(&cfg).expect("engine create");
|
||||
|
||||
@@ -42,20 +42,22 @@ fn main() {
|
||||
let dir = PathBuf::from(&model_dir);
|
||||
let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
|
||||
|
||||
let mut cfg = OfflineTtsConfig::default();
|
||||
cfg.model = OfflineTtsModelConfig {
|
||||
pocket: OfflineTtsPocketModelConfig {
|
||||
lm_main: Some(p("lm_main.int8.onnx")),
|
||||
lm_flow: Some(p("lm_flow.int8.onnx")),
|
||||
encoder: Some(p("encoder.onnx")),
|
||||
decoder: Some(p("decoder.int8.onnx")),
|
||||
text_conditioner: Some(p("text_conditioner.onnx")),
|
||||
vocab_json: Some(p("vocab.json")),
|
||||
token_scores_json: Some(p("token_scores.json")),
|
||||
voice_embedding_cache_capacity: 16,
|
||||
let cfg = OfflineTtsConfig {
|
||||
model: OfflineTtsModelConfig {
|
||||
pocket: OfflineTtsPocketModelConfig {
|
||||
lm_main: Some(p("lm_main.int8.onnx")),
|
||||
lm_flow: Some(p("lm_flow.int8.onnx")),
|
||||
encoder: Some(p("encoder.onnx")),
|
||||
decoder: Some(p("decoder.int8.onnx")),
|
||||
text_conditioner: Some(p("text_conditioner.onnx")),
|
||||
vocab_json: Some(p("vocab.json")),
|
||||
token_scores_json: Some(p("token_scores.json")),
|
||||
voice_embedding_cache_capacity: 16,
|
||||
},
|
||||
num_threads: 1,
|
||||
debug: false,
|
||||
..Default::default()
|
||||
},
|
||||
num_threads: 1,
|
||||
debug: false,
|
||||
..Default::default()
|
||||
};
|
||||
let engine = OfflineTts::create(&cfg).expect("engine create");
|
||||
|
||||
@@ -98,20 +98,22 @@ fn main() {
|
||||
let dir = PathBuf::from(&model_dir);
|
||||
let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
|
||||
|
||||
let mut cfg = OfflineTtsConfig::default();
|
||||
cfg.model = OfflineTtsModelConfig {
|
||||
pocket: OfflineTtsPocketModelConfig {
|
||||
lm_main: Some(p("lm_main.int8.onnx")),
|
||||
lm_flow: Some(p("lm_flow.int8.onnx")),
|
||||
encoder: Some(p("encoder.onnx")),
|
||||
decoder: Some(p("decoder.int8.onnx")),
|
||||
text_conditioner: Some(p("text_conditioner.onnx")),
|
||||
vocab_json: Some(p("vocab.json")),
|
||||
token_scores_json: Some(p("token_scores.json")),
|
||||
voice_embedding_cache_capacity: 16,
|
||||
let cfg = OfflineTtsConfig {
|
||||
model: OfflineTtsModelConfig {
|
||||
pocket: OfflineTtsPocketModelConfig {
|
||||
lm_main: Some(p("lm_main.int8.onnx")),
|
||||
lm_flow: Some(p("lm_flow.int8.onnx")),
|
||||
encoder: Some(p("encoder.onnx")),
|
||||
decoder: Some(p("decoder.int8.onnx")),
|
||||
text_conditioner: Some(p("text_conditioner.onnx")),
|
||||
vocab_json: Some(p("vocab.json")),
|
||||
token_scores_json: Some(p("token_scores.json")),
|
||||
voice_embedding_cache_capacity: 16,
|
||||
},
|
||||
num_threads: 1,
|
||||
debug: false,
|
||||
..Default::default()
|
||||
},
|
||||
num_threads: 1,
|
||||
debug: false,
|
||||
..Default::default()
|
||||
};
|
||||
let engine = OfflineTts::create(&cfg).expect("engine create");
|
||||
@@ -199,8 +201,8 @@ fn find_gap(samples: &[f32], sr: u32, thresh: f32, min_ms: u32) -> String {
|
||||
let scan_start = (sr as usize * 30) / 1000;
|
||||
let min_samples = (sr as usize * min_ms as usize) / 1000;
|
||||
let mut silence_from: Option<usize> = None;
|
||||
for i in scan_start..samples.len() {
|
||||
if samples[i].abs() < thresh {
|
||||
for (i, sample) in samples.iter().enumerate().skip(scan_start) {
|
||||
if sample.abs() < thresh {
|
||||
silence_from.get_or_insert(i);
|
||||
} else if let Some(start) = silence_from {
|
||||
if i - start >= min_samples {
|
||||
|
||||
@@ -277,7 +277,7 @@ mod tests {
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let agent_compat = nostr::PublicKey::from_hex(&agent_hex).unwrap();
|
||||
let owner_compat_secret =
|
||||
nostr::SecretKey::from_slice(&owner.secret_key().as_secret_bytes()[..]).unwrap();
|
||||
nostr::SecretKey::from_slice(owner.secret_key().as_secret_bytes()).unwrap();
|
||||
let owner_compat_keys = nostr::Keys::new(owner_compat_secret);
|
||||
let tag_json = sprout_sdk::nip_oa::compute_auth_tag(&owner_compat_keys, &agent_compat, "")
|
||||
.expect("compute_auth_tag");
|
||||
|
||||
@@ -133,7 +133,7 @@ fn sign_blossom_upload_auth(
|
||||
Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()])
|
||||
.map_err(|e| e.to_string())?,
|
||||
];
|
||||
if let Some(domain) = extract_server_authority(&base_url) {
|
||||
if let Some(domain) = extract_server_authority(base_url) {
|
||||
tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?);
|
||||
}
|
||||
EventBuilder::new(Kind::from(24242), "Upload sprout-media")
|
||||
@@ -265,7 +265,7 @@ fn find_ffmpeg() -> Result<std::path::PathBuf, String> {
|
||||
|
||||
/// Detect if a file is a video based on magic bytes.
|
||||
fn is_video_file(buf: &[u8]) -> bool {
|
||||
infer::get(buf).map_or(false, |t| t.mime_type().starts_with("video/"))
|
||||
infer::get(buf).is_some_and(|t| t.mime_type().starts_with("video/"))
|
||||
}
|
||||
|
||||
/// Maximum wall-clock time for an ffmpeg transcode before we kill it.
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn download_image(
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.path_segments()?
|
||||
.last()
|
||||
.next_back()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
|
||||
@@ -295,14 +295,11 @@ async fn pairing_ws_task_inner(
|
||||
let mut guard = session.lock().await;
|
||||
let Some(s) = guard.as_mut() else { break };
|
||||
|
||||
match s.handle_abort(&event) {
|
||||
Ok(reason) => {
|
||||
let _ = app.emit("pairing-aborted", PairingAbortedPayload {
|
||||
reason: format!("{reason:?}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(_) => {}
|
||||
if let Ok(reason) = s.handle_abort(&event) {
|
||||
let _ = app.emit("pairing-aborted", PairingAbortedPayload {
|
||||
reason: format!("{reason:?}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if let Ok(sas) = s.handle_offer(&event) {
|
||||
|
||||
@@ -67,7 +67,7 @@ pub async fn add_relay_member(
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let builder = events::build_relay_admin_add(&target_pubkey, &role)?;
|
||||
let result = submit_event(builder, &state).await?;
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
serde_json::to_value(result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -77,7 +77,7 @@ pub async fn remove_relay_member(
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let builder = events::build_relay_admin_remove(&target_pubkey)?;
|
||||
let result = submit_event(builder, &state).await?;
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
serde_json::to_value(result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -88,5 +88,5 @@ pub async fn change_relay_member_role(
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let builder = events::build_relay_admin_change_role(&target_pubkey, &new_role)?;
|
||||
let result = submit_event(builder, &state).await?;
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
serde_json::to_value(result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ pub async fn get_liked_notes(
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
reactions.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
reactions.sort_by_key(|reaction| std::cmp::Reverse(reaction.created_at));
|
||||
|
||||
let reaction_ids: Vec<String> = reactions.iter().map(|event| event.id.to_hex()).collect();
|
||||
let deletions = if reaction_ids.is_empty() {
|
||||
@@ -395,7 +395,7 @@ pub async fn get_notes_timeline(
|
||||
.collect();
|
||||
|
||||
// Sort newest-first.
|
||||
notes.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
notes.sort_by_key(|note| std::cmp::Reverse(note.created_at));
|
||||
notes.truncate(200);
|
||||
|
||||
Ok(UserNotesResponse {
|
||||
|
||||
@@ -664,13 +664,12 @@ impl ModelManager {
|
||||
MAX_STT_DOWNLOAD_BYTES,
|
||||
"stt archive",
|
||||
|downloaded, content_length| {
|
||||
if let Some(total) = content_length {
|
||||
if total > 0 {
|
||||
let pct = ((downloaded * 89) / total).min(89) as u8;
|
||||
slot.set_status(ModelStatus::Downloading {
|
||||
progress_percent: pct,
|
||||
});
|
||||
}
|
||||
if let Some(pct) =
|
||||
content_length.and_then(|total| (downloaded * 89).checked_div(total))
|
||||
{
|
||||
slot.set_status(ModelStatus::Downloading {
|
||||
progress_percent: pct.min(89) as u8,
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -779,10 +778,11 @@ impl ModelManager {
|
||||
for (i, (url, filename)) in downloads.iter().enumerate() {
|
||||
eprintln!("sprout-desktop: downloading Pocket TTS {filename} from {url}");
|
||||
|
||||
let response = fetch_url(&http_client, url, filename).await.map_err(|e| {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
e
|
||||
})?;
|
||||
let response = fetch_url(&http_client, url, filename)
|
||||
.await
|
||||
.inspect_err(|_| {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
})?;
|
||||
|
||||
let dest = temp_dir.join(filename);
|
||||
let slot = self.tts.clone();
|
||||
@@ -807,9 +807,8 @@ impl ModelManager {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.inspect_err(|_| {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
e
|
||||
})?;
|
||||
eprintln!("sprout-desktop: downloaded {bytes} bytes ({filename}), wrote to disk");
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(crate) async fn maybe_start_stt_pipeline(
|
||||
if !models::is_stt_ready() {
|
||||
return Ok(false); // Models not downloaded yet — voice-only mode.
|
||||
}
|
||||
let model_dir = models::stt_model_dir().ok_or_else(|| "STT model directory not found")?;
|
||||
let model_dir = models::stt_model_dir().ok_or("STT model directory not found")?;
|
||||
|
||||
let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
//! - `load_text_to_speech(model_dir)` → `Result<Engine, String>`
|
||||
//! - `load_voice_style(path)` → `Result<VoiceStyle, String>`
|
||||
//! - `Engine::synth_chunk(&self, text, lang, &VoiceStyle, steps, speed)`
|
||||
//! → `Result<Vec<f32>, String>`
|
||||
//! → `Result<Vec<f32>, String>`
|
||||
//!
|
||||
//! `lang` and `steps` are accepted for API compatibility with the previous
|
||||
//! Kokoro engine but are unused — Pocket TTS does its own language ID from
|
||||
@@ -589,10 +589,12 @@ mod tests {
|
||||
assert_eq!(out.text, format!("{}Yep.", short_prefix()));
|
||||
assert!(out.is_short, "1-word input is short");
|
||||
assert_eq!(out.max_frames, Some(SHORT_PROMPT_MAX_FRAMES));
|
||||
assert!(
|
||||
SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT,
|
||||
"short cap must be tighter than the upstream default"
|
||||
);
|
||||
const {
|
||||
assert!(
|
||||
SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT,
|
||||
"short cap must be tighter than the upstream default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -758,10 +760,14 @@ mod tests {
|
||||
#[test]
|
||||
fn short_prompt_max_frames_is_below_upstream_default() {
|
||||
// Sanity: the override only ever *lowers* the cap, never raises it.
|
||||
assert!(SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT);
|
||||
const {
|
||||
assert!(SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT);
|
||||
}
|
||||
// …and is still large enough for a one-to-four-word reply. At Mimi's
|
||||
// 12.5 Hz frame rate, 100 frames = 8 s, which is roomy.
|
||||
assert!(SHORT_PROMPT_MAX_FRAMES >= 50, "would risk truncation");
|
||||
const {
|
||||
assert!(SHORT_PROMPT_MAX_FRAMES >= 50, "would risk truncation");
|
||||
}
|
||||
}
|
||||
|
||||
// ── trim_leading_cold_start ──────────────────────────────────────────────
|
||||
|
||||
@@ -40,7 +40,7 @@ const ABBREVIATIONS: &[&str] = &[
|
||||
/// Returns non-empty, trimmed strings.
|
||||
pub fn split_sentences(text: &str) -> Vec<String> {
|
||||
// First, split on newlines and em-dashes to get coarse segments.
|
||||
let coarse: Vec<&str> = text.split(|c: char| c == '\n' || c == '—').collect();
|
||||
let coarse: Vec<&str> = text.split(['\n', '—']).collect();
|
||||
|
||||
let mut sentences = Vec::new();
|
||||
|
||||
@@ -240,7 +240,7 @@ fn strip_urls(text: &str) -> String {
|
||||
// belongs to the surrounding sentence rather than the URL itself.
|
||||
// A trailing `.`, `!`, or `?` is preserved when it is at end-of-string
|
||||
// or followed by whitespace (i.e. it is a sentence boundary).
|
||||
let trailing_punct = if url_token.ends_with(|c: char| matches!(c, '.' | '!' | '?')) {
|
||||
let trailing_punct = if url_token.ends_with(['.', '!', '?']) {
|
||||
let after = rest; // rest is already past url_end
|
||||
if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) {
|
||||
// Preserve the trailing punctuation.
|
||||
|
||||
@@ -170,17 +170,17 @@ pub(crate) async fn connect_audio_relay(
|
||||
.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = audio_relay_pipeline(
|
||||
if let Err(e) = audio_relay_pipeline(AudioRelayPipelineArgs {
|
||||
ws_tx,
|
||||
ws_rx,
|
||||
pcm_rx,
|
||||
cancel_clone.clone(),
|
||||
app_handle.clone(),
|
||||
cancel: cancel_clone.clone(),
|
||||
app_handle: app_handle.clone(),
|
||||
initial_peers,
|
||||
tts_cancel,
|
||||
tts_active,
|
||||
output_device_name,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
eprintln!("sprout-desktop: audio relay pipeline exited: {e}");
|
||||
@@ -204,17 +204,31 @@ pub(crate) async fn connect_audio_relay(
|
||||
pub(crate) type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
async fn audio_relay_pipeline(
|
||||
struct AudioRelayPipelineArgs {
|
||||
ws_tx: futures_util::stream::SplitSink<WsStream, WsMsg>,
|
||||
ws_rx: futures_util::stream::SplitStream<WsStream>,
|
||||
mut pcm_rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||
pcm_rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||
cancel: CancellationToken,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
initial_peers: Vec<(u8, String)>,
|
||||
tts_cancel: Arc<AtomicBool>,
|
||||
tts_active: Arc<AtomicBool>,
|
||||
output_device_name: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
}
|
||||
|
||||
async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String> {
|
||||
let AudioRelayPipelineArgs {
|
||||
ws_tx,
|
||||
ws_rx,
|
||||
mut pcm_rx,
|
||||
cancel,
|
||||
app_handle,
|
||||
initial_peers,
|
||||
tts_cancel,
|
||||
tts_active,
|
||||
output_device_name,
|
||||
} = args;
|
||||
|
||||
let mut encoder = opus::Encoder::new(48000, opus::Channels::Mono, opus::Application::Voip)
|
||||
.map_err(|e| format!("opus encoder: {e}"))?;
|
||||
encoder
|
||||
@@ -383,7 +397,7 @@ pub(crate) async fn fetch_channel_members(
|
||||
let all = fetch_channel_members_with_roles(channel_id, state).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|(_, role)| role_filter.map_or(true, |r| role.as_deref() == Some(r)))
|
||||
.filter(|(_, role)| role_filter.is_none_or(|r| role.as_deref() == Some(r)))
|
||||
.map(|(pubkey, _)| pubkey)
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ impl SttPipeline {
|
||||
/// Returns `true` if the worker thread has exited (init failure, crash, or normal exit).
|
||||
/// Used by hot-start to detect dead pipelines and clear them for retry.
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.thread.as_ref().map_or(true, |h| h.is_finished())
|
||||
self.thread.as_ref().is_none_or(|h| h.is_finished())
|
||||
}
|
||||
|
||||
/// Feed raw PCM bytes into the pipeline.
|
||||
@@ -136,7 +136,7 @@ impl SttPipeline {
|
||||
/// better to lose frames than to stall the UI thread.
|
||||
pub fn push_audio(&self, pcm_bytes: Vec<u8>) -> Result<(), String> {
|
||||
// Reject non-4-byte-aligned input — would silently truncate in bytes_to_f32.
|
||||
if pcm_bytes.len() % 4 != 0 {
|
||||
if !pcm_bytes.len().is_multiple_of(4) {
|
||||
return Err(format!(
|
||||
"audio input not 4-byte aligned ({} bytes) — expected f32 LE samples",
|
||||
pcm_bytes.len()
|
||||
@@ -283,7 +283,7 @@ fn stt_worker(
|
||||
let mut tts_was_active = false;
|
||||
let mut ptt_was_active = ptt_active
|
||||
.as_ref()
|
||||
.map_or(false, |p| p.load(Ordering::Acquire));
|
||||
.is_some_and(|p| p.load(Ordering::Acquire));
|
||||
loop {
|
||||
// Check shutdown flag before blocking.
|
||||
if shutdown.load(Ordering::Acquire) {
|
||||
|
||||
@@ -221,7 +221,7 @@ impl TtsPipeline {
|
||||
/// Returns `true` if the worker thread has exited (init failure, crash, or normal exit).
|
||||
/// Used by hot-start to detect dead pipelines and clear them for retry.
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.thread.as_ref().map_or(true, |h| h.is_finished())
|
||||
self.thread.as_ref().is_none_or(|h| h.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ fn tts_worker(
|
||||
let channels = NonZero::new(1u16).unwrap();
|
||||
let rate = NonZero::new(SAMPLE_RATE).unwrap();
|
||||
let silence = vec![0.0f32; SAMPLE_RATE as usize / 10]; // 100ms of silence
|
||||
let player = Player::connect_new(&sink_handle.mixer());
|
||||
let player = Player::connect_new(sink_handle.mixer());
|
||||
player.append(SamplesBuffer::new(channels, rate, silence));
|
||||
// Wait for the silent buffer to drain — this ensures the output stream
|
||||
// is fully initialized before the main loop creates its first Player.
|
||||
@@ -387,7 +387,7 @@ fn tts_worker(
|
||||
|
||||
// Single persistent Player — all sentences append here, rodio plays
|
||||
// them gaplessly without per-sentence device setup overhead.
|
||||
let player = Player::connect_new(&sink_handle.mixer());
|
||||
let player = Player::connect_new(sink_handle.mixer());
|
||||
// NOTE: tts_active is set AFTER the first player.append(), not before.
|
||||
// Setting it before synthesis would cause STT to discard user speech
|
||||
// during the synthesis window as "echo" even though no audio is
|
||||
|
||||
@@ -461,7 +461,7 @@ pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
|
||||
|
||||
let underlying_cli_path = provider
|
||||
.underlying_cli
|
||||
.and_then(|cli| find_command(cli))
|
||||
.and_then(find_command)
|
||||
.map(|p| p.display().to_string());
|
||||
|
||||
let default_args = command
|
||||
|
||||
@@ -759,8 +759,8 @@ mod tests {
|
||||
let external = tmp.path().join("external");
|
||||
fs::create_dir(&external).unwrap();
|
||||
fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::remove_dir(&root.join("REPOS")).unwrap();
|
||||
std::os::unix::fs::symlink(&external, &root.join("REPOS")).unwrap();
|
||||
fs::remove_dir(root.join("REPOS")).unwrap();
|
||||
std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap();
|
||||
|
||||
// Second call should succeed — it skips chmod on the symlinked child.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
@@ -7,6 +7,9 @@ use crate::util;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::Manager;
|
||||
|
||||
type SpawnResult = Result<(std::process::Child, std::path::PathBuf), String>;
|
||||
type AgentSpawnResult = (String, SpawnResult);
|
||||
|
||||
/// Restore managed agents that were running before the app was closed.
|
||||
///
|
||||
/// Split into three phases to minimise lock contention with the frontend:
|
||||
@@ -94,10 +97,7 @@ pub fn restore_managed_agents_on_launch(
|
||||
.map(|k| k.public_key().to_hex());
|
||||
|
||||
// ── Phase B (no locks): resolve commands and spawn processes in parallel ──
|
||||
let spawn_results: Vec<(
|
||||
String,
|
||||
Result<(std::process::Child, std::path::PathBuf), String>,
|
||||
)> = std::thread::scope(|scope| {
|
||||
let spawn_results: Vec<AgentSpawnResult> = std::thread::scope(|scope| {
|
||||
let owner_hex_ref = owner_hex.as_deref();
|
||||
let handles: Vec<_> = agents_to_start
|
||||
.iter()
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::{
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
|
||||
|
||||
/// Binary name fragments for all known agent/harness processes that Sprout
|
||||
/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to
|
||||
/// identify processes we should clean up. Both hyphenated and underscored
|
||||
@@ -459,7 +461,7 @@ pub fn find_managed_agent_mut<'a>(
|
||||
pub(crate) fn build_respond_to_env(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_hex: Option<&str>,
|
||||
) -> Result<(Vec<(&'static str, String)>, Vec<&'static str>), String> {
|
||||
) -> Result<RespondToEnv, String> {
|
||||
// Defensive re-validation: an on-disk record could have been hand-edited.
|
||||
let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?;
|
||||
if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() {
|
||||
|
||||
@@ -188,7 +188,7 @@ pub fn read_log_tail(path: &Path, max_lines: usize) -> Result<String, String> {
|
||||
// Strip ANSI escapes here (not in the harness) so the desktop log view
|
||||
// renders cleanly while terminals and other tools still get the colors
|
||||
// sprout-acp emits.
|
||||
let cleaned = strip_ansi_escapes::strip_str(&String::from_utf8_lossy(&buf));
|
||||
let cleaned = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&buf));
|
||||
let lines: Vec<&str> = cleaned.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
Ok(lines[start..].join("\n"))
|
||||
|
||||
@@ -696,7 +696,7 @@ mod tests {
|
||||
fn validate_respond_to_allowlist_accepts_valid_hex_and_lowercases() {
|
||||
let upper = "A".repeat(64);
|
||||
let lower = "a".repeat(64);
|
||||
let result = validate_respond_to_allowlist(&[upper.clone()]).unwrap();
|
||||
let result = validate_respond_to_allowlist(std::slice::from_ref(&upper)).unwrap();
|
||||
assert_eq!(result, vec![lower.clone()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,9 +111,7 @@ async fn proxy_handler(AxumState(state): AxumState<ProxyState>, req: Request) ->
|
||||
}
|
||||
|
||||
// Stream the body — no buffering.
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
let stream = resp.bytes_stream().map_err(std::io::Error::other);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
(status, headers, body).into_response()
|
||||
|
||||
@@ -1046,7 +1046,7 @@ mod tests {
|
||||
#[test]
|
||||
fn agents_overwrites_pubkey_from_event_author() {
|
||||
let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]);
|
||||
let v = agents_from_events(&[e.clone()]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let arr = v.get("agents").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -1059,7 +1059,7 @@ mod tests {
|
||||
#[test]
|
||||
fn agents_handles_invalid_content() {
|
||||
let e = ev(10100, "not-json", vec![]);
|
||||
let v = agents_from_events(&[e.clone()]);
|
||||
let v = agents_from_events(std::slice::from_ref(&e));
|
||||
let arr = v.get("agents").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(
|
||||
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
|
||||
|
||||
@@ -61,8 +61,8 @@ pub fn acquire(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let assertion_type = b"PreventUserIdleSystemSleep\0".as_ptr() as *const std::ffi::c_char;
|
||||
let reason = b"Sprout \xe2\x80\x94 agents are active\0".as_ptr() as *const std::ffi::c_char;
|
||||
let assertion_type = c"PreventUserIdleSystemSleep".as_ptr();
|
||||
let reason = c"Sprout \u{2014} agents are active".as_ptr();
|
||||
|
||||
unsafe {
|
||||
let cf_type = macos::CFStringCreateWithCString(
|
||||
|
||||
@@ -406,8 +406,11 @@ release *ARGS:
|
||||
echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')"
|
||||
exit 1
|
||||
fi
|
||||
# Ensure local main is up-to-date
|
||||
git fetch origin main --tags --quiet
|
||||
# Ensure local main and release tags are up-to-date.
|
||||
git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags
|
||||
# Release tags are remote-owned state; sync only v* tags so stale local
|
||||
# tags from older histories do not make release preflight fail.
|
||||
git fetch origin '+refs/tags/v*:refs/tags/v*'
|
||||
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
|
||||
echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first."
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user