Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src-tauri/crates/berd-call/src/cli_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const START_HELP: &str = r#"Run a local voice call backed by the shared session

Usage:
berd-call start [--port PORT] [--stream | --codex] [--non-blocking]
[--no-menu-bar]
[--no-menu-bar] [--client terminal|buzz-plugin]
--voice NAME --language BCP47 [session options]

The foreground process owns the default microphone and audio output. Session
Expand All @@ -50,7 +50,8 @@ Microphone mute is temporary; each new call starts unmuted. Explicit startup
options override saved defaults. Preferences are stored in
$XDG_CONFIG_HOME/berd-call/settings.json or
$HOME/.config/berd-call/settings.json. BERD_CALL_SETTINGS_FILE selects an
alternate file. Agent identity and transcript routing are never saved."#;
alternate file. `--client` labels usage telemetry for this call only and defaults
to `terminal`; the Buzz plugin passes `buzz-plugin`. It is never saved. Agent identity and transcript routing are never saved."#;

const SPEAK_HELP: &str = r#"Speak through the active local voice call.

Expand Down
4 changes: 3 additions & 1 deletion src-tauri/crates/berd-call/src/host_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub(crate) fn run(options: StartOptions) -> Result<(), String> {
&non_blocking,
&stop_requested,
&status_sounds,
options.client,
) {
Ok(None) => break Ok(()),
// A stop recorded while the old session was shutting down wins
Expand Down Expand Up @@ -218,6 +219,7 @@ fn run_session(
non_blocking: &Arc<AtomicBool>,
stop_requested: &Arc<AtomicBool>,
status_sounds: &Arc<Mutex<StatusSounds>>,
client: crate::telemetry::Client,
) -> Result<Option<SessionStart>, String> {
let SessionStart {
saved,
Expand Down Expand Up @@ -269,7 +271,7 @@ fn run_session(
.lock()
.map(|session| session.tts.settings.clone())
{
crate::telemetry::start(&control.session_arguments, &tts);
crate::telemetry::start(&control.session_arguments, &tts, client);
}
let control: Arc<dyn HostControl> = control;
while running.load(Ordering::SeqCst) {
Expand Down
38 changes: 38 additions & 0 deletions src-tauri/crates/berd-call/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,7 @@ enum TranscriptDestination {
}

struct StartOptions {
client: telemetry::Client,
saved: Option<(PathBuf, saved_settings::SavedSettings)>,
port: u16,
transcript: TranscriptDestination,
Expand Down Expand Up @@ -796,6 +797,7 @@ fn validate_session_arguments(arguments: &[String]) -> Result<(), String> {
fn parse_start_args(args: &[String]) -> Result<StartOptions, ParseFailure> {
let mut port = 5222_u16;
let mut port_seen = false;
let mut client = None;
let mut transcript = TranscriptDestination::None;
let mut menu_bar = true;
let mut non_blocking = false;
Expand All @@ -815,6 +817,12 @@ fn parse_start_args(args: &[String]) -> Result<StartOptions, ParseFailure> {
TranscriptDestination::Stdout
};
}
"--client" if client.is_none() => {
let value = args.get(index + 1).ok_or("--client requires a value")?;
client = Some(telemetry::Client::parse(value)?);
index += 2;
}
"--client" => return Err("--client may be provided only once".into()),
"--no-menu-bar" if menu_bar => {
menu_bar = false;
index += 1;
Expand Down Expand Up @@ -851,6 +859,7 @@ fn parse_start_args(args: &[String]) -> Result<StartOptions, ParseFailure> {
);
}
Ok(StartOptions {
client: client.unwrap_or_default(),
saved: None,
port,
transcript,
Expand Down Expand Up @@ -8507,6 +8516,35 @@ mod tests {
);
}

#[test]
fn client_is_bounded_and_not_forwarded_as_a_speech_preference() {
let start = |flags: &[&str]| {
let mut all = vec!["berd-call", "start"];
all.extend_from_slice(flags);
all.extend(["--voice", "Aaron", "--language", "en-US"]);
parse_start_args(&args(&all))
};
assert_eq!(start(&[]).unwrap().client, telemetry::Client::Terminal);
for (value, expected) in [
("terminal", telemetry::Client::Terminal),
("buzz-plugin", telemetry::Client::BuzzPlugin),
] {
let options = start(&["--client", value]).unwrap();
assert_eq!(options.client, expected);
assert_eq!(
options.session_arguments,
["session", "--voice", "Aaron", "--language", "en-US"]
);
}
for args in [
&["--client"][..],
&["--client", "private"],
&["--client", "buzz-plugin", "--client", "terminal"],
] {
assert!(start(args).is_err());
}
}

#[test]
fn start_parser_keeps_only_session_options_for_the_child() {
let parsed = parse_start_args(&args(&[
Expand Down
70 changes: 64 additions & 6 deletions src-tauri/crates/berd-call/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use berd_call::telemetry_store as store;
use berd_call::TtsSettings;
use serde_json::{json, Value};

const SCHEMA_VERSION: &str = "berd-call-otlp-logs-v1";
const SCHEMA_VERSION: &str = "berd-call-otlp-logs-v3";
const ENFORCED: bool = cfg!(feature = "telemetry-enforced");
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
Expand Down Expand Up @@ -92,9 +92,34 @@ fn command_in(dir: &Path, args: &[&str]) -> Result<Value, String> {
Ok(json!({"enabled": consented(dir), "enforced": ENFORCED}))
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Client {
#[default]
Terminal,
BuzzPlugin,
}

impl Client {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
match value {
"terminal" => Ok(Self::Terminal),
"buzz-plugin" => Ok(Self::BuzzPlugin),
_ => Err("--client must be terminal or buzz-plugin".into()),
}
}

fn as_str(self) -> &'static str {
match self {
Self::Terminal => "terminal",
Self::BuzzPlugin => "buzz-plugin",
}
}
}

/// The call's audio setup, as Berd's voice events report it.
#[derive(Clone, Debug, PartialEq)]
struct Context {
client: Client,
input: &'static str,
output: &'static str,
rate: Option<String>,
Expand All @@ -105,6 +130,7 @@ impl Context {
/// Reports the selected voice only from its backend's own catalog, and any
/// other voice as "custom", so free-form names never leave the machine.
fn new(
client: Client,
session_arguments: &[String],
tts: &TtsSettings,
siri_voices: impl FnOnce() -> Vec<String>,
Expand All @@ -128,6 +154,7 @@ impl Context {
};
let (rate, voice) = (tts.rate(), tts.voice());
Some(Self {
client,
input: ["macos", "parakeet", "openai"]
.into_iter()
.find(|known| *known == input)?,
Expand All @@ -142,6 +169,7 @@ impl Context {
("input_backend", self.input.to_string()),
("output_backend", self.output.to_string()),
("voice_mode", "chained".to_string()),
("client", self.client.as_str().to_string()),
];
attributes.extend(self.rate.clone().map(|rate| ("tts_rate", rate)));
attributes.extend(self.voice.clone().map(|voice| ("tts_voice", voice)));
Expand Down Expand Up @@ -174,15 +202,15 @@ pub(crate) fn user_stopped() {
}

/// Reports the call's start once, when its first session is ready.
pub(crate) fn start(session_arguments: &[String], tts: &TtsSettings) {
pub(crate) fn start(session_arguments: &[String], tts: &TtsSettings, call_client: Client) {
let (Some(build), Some(dir)) = (build(), berd_dir()) else {
return;
};
let Ok(mut call) = CALL.lock() else { return };
if call.is_some() || !consented(&dir) {
return;
}
let Some(context) = Context::new(session_arguments, tts, || {
let Some(context) = Context::new(call_client, session_arguments, tts, || {
berd_call::siri::load_voice_catalog(None)
.map(|catalog| catalog.voices.into_iter().map(|voice| voice.name).collect())
.unwrap_or_default()
Expand Down Expand Up @@ -383,7 +411,9 @@ mod tests {
}

fn context(values: &[&str], tts: &TtsSettings) -> Option<Context> {
Context::new(&arguments(values), tts, || vec!["Samantha".into()])
Context::new(Client::Terminal, &arguments(values), tts, || {
vec!["Samantha".into()]
})
}

fn arguments(values: &[&str]) -> Vec<String> {
Expand All @@ -394,6 +424,32 @@ mod tests {
UNIX_EPOCH + Duration::from_secs(seconds)
}

#[test]
fn both_lifecycle_records_retain_the_starting_client() {
for client in [Client::Terminal, Client::BuzzPlugin] {
let context = Context {
client,
..context(&["session"], &pocket(1.0)).unwrap()
};
let started = record(
"berd_voice_conversation_started",
at(1),
&context.attributes(),
);
let ended = record(
"berd_voice_conversation_ended",
at(2),
&ended_attributes(&context, Duration::from_secs(1), 0, 0, "user"),
);
for event in [started, ended] {
assert!(event["attributes"]
.as_array()
.unwrap()
.contains(&json!({"key":"client", "value":{"stringValue":client.as_str()}})));
}
}
}

#[test]
fn context_reports_backends_and_a_valid_rate() {
let parakeet = context(
Expand All @@ -413,6 +469,7 @@ mod tests {
("input_backend", "parakeet".to_string()),
("output_backend", "pocket".to_string()),
("voice_mode", "chained".to_string()),
("client", "terminal".to_string()),
("tts_rate", "1.25".to_string()),
("tts_voice", "anna".to_string()),
]
Expand Down Expand Up @@ -486,6 +543,7 @@ mod tests {
{"key": "input_backend", "value": {"stringValue": "parakeet"}},
{"key": "output_backend", "value": {"stringValue": "pocket"}},
{"key": "voice_mode", "value": {"stringValue": "chained"}},
{"key": "client", "value": {"stringValue": "terminal"}},
{"key": "tts_rate", "value": {"stringValue": "1.25"}},
{"key": "tts_voice", "value": {"stringValue": "anna"}},
]);
Expand Down Expand Up @@ -690,7 +748,7 @@ mod tests {
.collect::<Vec<_>>()
};
assert_eq!(header("authorization"), ["Bearer token-1"]);
assert_eq!(header("x-berd-schema-version"), ["berd-call-otlp-logs-v1"]);
assert_eq!(header("x-berd-schema-version"), ["berd-call-otlp-logs-v3"]);
assert_eq!(header("content-type"), ["application/json"]);
let resource = &requests[1].body["resourceLogs"][0]["resource"]["attributes"];
assert_eq!(
Expand Down Expand Up @@ -725,7 +783,7 @@ mod tests {
"clean-shutdown",
);
assert_eq!(
&attributes[4..],
&attributes[5..],
[
("duration_ms", "1500".to_string()),
("user_utterance_count", "0".to_string()),
Expand Down
Loading