use super::operations::{
hear_segment, now_ms, segment_clip, Ears, CAPTURE_RATE, DEFAULT_PROMPT, HEAR_RATE,
};
use super::segmenter::{Segmenter, VadConfig};
use super::{ModelConfig, Observation, Options, Outcome};
use crate::out::Out;
use crate::turntaking::{self, SpeechWindow};
use anyhow::{bail, Context, Result};
use clap::{Args, CommandFactory, Parser, Subcommand};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
version = crate::GIT_VERSION,
name = "hear",
about = "Ears: Soma's framed microphone → utterances → audio embeddings."
)]
pub struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Listen(ListenArgs),
Once(OnceArgs),
}
#[derive(Args, Debug, Clone)]
struct Shared {
#[arg(long, env = "GEMMA_PILE")]
pile: Option<PathBuf>,
#[arg(long, default_value = "google/gemma-4-E4B-it")]
model: String,
#[arg(long, default_value = "/tmp/hear.jsonl")]
out: PathBuf,
#[arg(long, default_value = "/tmp/hear_emb")]
emb_dir: PathBuf,
#[arg(long, default_value_t = false)]
transcribe: bool,
#[arg(long, default_value = DEFAULT_PROMPT)]
prompt: String,
#[arg(long, default_value_t = 64)]
tokens: usize,
#[arg(long, default_value_t = 2)]
min_chars: usize,
#[arg(long, default_value_t = 0.6)]
min_dur_s: f64,
#[arg(long, default_value_t = turntaking::DEFAULT_BARGE_GRACE_MS)]
barge_grace_ms: u64,
}
#[derive(Args, Debug)]
struct ListenArgs {
#[command(flatten)]
shared: Shared,
#[arg(long, env = "SOMA_URL", default_value = soma_client::DEFAULT_BASE)]
soma: String,
#[arg(long, env = "VOICE_PAUSE_FILE")]
pause_file: Option<PathBuf>,
#[arg(long, default_value_t = 0)]
limit: usize,
}
#[derive(Args, Debug)]
struct OnceArgs {
#[command(flatten)]
shared: Shared,
#[arg(long, value_delimiter = ',', required = true)]
wav: Vec<PathBuf>,
}
impl Shared {
fn options(&self) -> Result<Options> {
let options = Options {
transcribe: self.transcribe,
prompt: crate::text_arg(&self.prompt, "hearing prompt")?,
tokens: self.tokens,
filter: turntaking::SpeechFilter {
min_chars: self.min_chars,
min_dur_s: self.min_dur_s,
barge_grace_ms: self.barge_grace_ms,
},
};
options.validate()?;
Ok(options)
}
}
#[cfg(feature = "hear")]
fn model_config(shared: &Shared) -> Result<ModelConfig> {
let pile = shared
.pile
.clone()
.context("no Gemma pile: pass --pile or set GEMMA_PILE")?;
Ok(ModelConfig {
pile,
model: shared.model.clone(),
config_json: find_hf_file(&shared.model, "config.json")?,
tokenizer_json: find_hf_file(&shared.model, "tokenizer.json")?,
})
}
#[cfg(not(feature = "hear"))]
fn model_config(_shared: &Shared) -> Result<ModelConfig> {
bail!("hear was built without the `hear` feature")
}
#[cfg(feature = "hear")]
fn find_hf_file(model: &str, filename: &str) -> Result<PathBuf> {
let output=std::process::Command::new("python3").args(["-c","import sys; from huggingface_hub import hf_hub_download; print(hf_hub_download(sys.argv[1], sys.argv[2], local_files_only=True))",model,filename]).output().with_context(||format!("resolve local hearing {filename}"))?;
if !output.status.success() {
bail!(
"could not resolve local {filename} for {model}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let path =
String::from_utf8(output.stdout).context("local hearing side-file path is not UTF-8")?;
let path = path.trim();
if path.is_empty() {
bail!("local hearing side-file resolver returned an empty path");
}
Ok(PathBuf::from(path))
}
#[cfg(feature = "hear")]
fn load_16k(path: &Path) -> Result<Vec<f32>> {
mary::models::gemma::gemma4::audio_load::load_audio_16k_mono(path).map_err(anyhow::Error::msg)
}
#[cfg(not(feature = "hear"))]
fn load_16k(_path: &Path) -> Result<Vec<f32>> {
bail!("hear was built without the `hear` feature")
}
fn cmd_once(args: OnceArgs, out: &mut Out<'_>) -> Result<()> {
let options = args.shared.options()?;
let mut ears = Ears::open(&model_config(&args.shared)?)?;
std::fs::create_dir_all(&args.shared.emb_dir)
.with_context(|| format!("create {}", args.shared.emb_dir.display()))?;
let mut kept = 0;
for path in &args.wav {
let wave = load_16k(path)?;
let segments = segment_clip(&wave)?;
out.line(format!(
"{}: {} utterance(s)",
path.display(),
segments.len()
))?;
for segment in segments {
let observation = hear_segment(
&mut ears,
&options,
&segment,
&path.display().to_string(),
None,
now_ms()?,
)?;
if observation.kept() {
kept += 1;
}
write_observation(&args.shared, &observation, out)?;
}
}
out.line(format!(
"{kept} utterance(s) embedded → {}",
args.shared.out.display()
))
}
fn cmd_listen(args: ListenArgs, out: &mut Out<'_>) -> Result<()> {
let shared = args.shared;
let options = shared.options()?;
let mut ears = Ears::open(&model_config(&shared)?)?;
std::fs::create_dir_all(&shared.emb_dir)
.with_context(|| format!("create {}", shared.emb_dir.display()))?;
if let Some(path) = &args.pause_file {
if turntaking::clear_stale(path) {
out.line(format!("cleared stale pause file {}", path.display()))?;
}
}
let mut capture = soma_client::SomaCapture::open(&args.soma)
.with_context(|| format!("open Soma capture at {}", args.soma))?;
out.line(format!(
"hear: soma={} {} Hz/{} sample frames; segmenter at {} Hz, model at {} Hz; out={} emb={}",
args.soma,
soma_client::SAMPLE_RATE,
soma_client::FRAME_SAMPLES,
CAPTURE_RATE,
HEAR_RATE,
shared.out.display(),
shared.emb_dir.display()
))?;
let mut segmenter = Segmenter::new(CAPTURE_RATE, VadConfig::default());
let mut spoke: Option<SpeechWindow> = None;
let mut pause_since = None;
let mut kept = 0;
loop {
let frame = capture.next_frame()?;
let held = args
.pause_file
.as_deref()
.map(turntaking::paused)
.unwrap_or(false);
if held {
if pause_since.is_none() {
pause_since = Some(now_ms()?);
}
segmenter.pause_skip(frame.samples.len() as u64);
continue;
}
if let Some(start_ms) = pause_since.take() {
spoke = Some(SpeechWindow {
start_ms,
end_ms: now_ms()?,
});
}
let mut segments = Vec::new();
segmenter.push(&frame.samples, &mut |segment| segments.push(segment));
for segment in segments {
let observation =
hear_segment(&mut ears, &options, &segment, "soma", spoke, now_ms()?)?;
let accepted = observation.kept();
write_observation(&shared, &observation, out)?;
if accepted {
kept += 1;
if args.limit > 0 && kept >= args.limit {
return out.line(format!("{kept} utterance(s) embedded — limit reached"));
}
}
}
}
}
fn write_observation(shared: &Shared, observation: &Observation, out: &mut Out<'_>) -> Result<()> {
let dur = observation.duration();
let record = match &observation.outcome {
Outcome::Dropped { reason, text } => {
match text {
Some(text) => {
out.line(format!("[heard ] {text:?} ({dur:.2}s) → DROPPED: {reason}"))?
}
None => out.line(format!("[heard ] ({dur:.2}s) → DROPPED: {reason}"))?,
}
let mut record = serde_json::json!({"utc_ms":observation.utc_ms,"source":observation.source,"start_s":observation.start_s,"end_s":observation.end_s,"dur_s":dur,"dropped":reason});
if let Some(text) = text {
record["text"] = serde_json::Value::String(text.clone());
}
record
}
Outcome::Embedded(heard) => {
let path = shared.emb_dir.join(format!(
"utt_{}_{:.0}ms.f32",
observation.utc_ms,
dur * 1000.0
));
let bytes = heard.embedding_bytes()?;
std::fs::write(&path, bytes.as_ref())
.with_context(|| format!("write {}", path.display()))?;
match &heard.text {
Some(text) => out.line(format!(
"[heard ] {text:?} ({dur:.2}s) → {} × {} embeddings",
heard.n_tokens, heard.hidden
))?,
None => out.line(format!(
"[heard ] ({dur:.2}s) → {} × {} embeddings",
heard.n_tokens, heard.hidden
))?,
}
serde_json::json!({"utc_ms":observation.utc_ms,"source":observation.source,"start_s":observation.start_s,"end_s":observation.end_s,"dur_s":dur,"rate":HEAR_RATE,"n_tokens":heard.n_tokens,"hidden":heard.hidden,"dtype":"f32le","layout":"row-major","emb":path.display().to_string(),"text":heard.text})
}
};
append_record(&shared.out, &record)
}
fn append_record(path: &Path, record: &serde_json::Value) -> Result<()> {
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open utterance log {}", path.display()))?;
writeln!(file, "{record}").with_context(|| format!("append utterance log {}", path.display()))
}
pub fn execute(cli: Cli, out: &mut Out<'_>) -> Result<()> {
match cli.command {
Some(Command::Listen(args)) => cmd_listen(args, out),
Some(Command::Once(args)) => cmd_once(args, out),
None => out.line(Cli::command().render_help().to_string()),
}
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
if cli.command.is_none() {
Cli::command().print_help()?;
println!();
return Ok(());
}
crate::cli::with_output("hear", |out| execute(cli, out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_cli_expands_prompt_file_input() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("prompt.txt");
std::fs::write(&path, "literal prompt contents").unwrap();
let at = format!("@{}", path.display());
let cli = Cli::try_parse_from(["hear", "once", "--wav", "unopened.wav", "--prompt", &at])
.unwrap();
let Some(Command::Once(args)) = cli.command else {
panic!("recorded clip syntax")
};
assert_eq!(
args.shared.options().unwrap().prompt,
"literal prompt contents"
);
let cli = Cli::try_parse_from([
"hear",
"once",
"--wav",
"unopened.wav",
"--prompt",
"@@literal",
])
.unwrap();
let Some(Command::Once(args)) = cli.command else {
panic!("recorded clip syntax")
};
assert_eq!(args.shared.options().unwrap().prompt, "@literal");
}
#[test]
fn cli_keeps_both_explicit_live_and_recorded_modes() {
let names: Vec<_> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_owned())
.collect();
assert!(names.contains(&"listen".to_owned()));
assert!(names.contains(&"once".to_owned()));
assert!(Cli::try_parse_from(["hear", "once"]).is_err());
let cli = Cli::try_parse_from([
"hear",
"listen",
"--soma",
"http://invalid.example",
"--pause-file",
"held",
"--limit",
"2",
])
.unwrap();
let Some(Command::Listen(args)) = cli.command else {
panic!("live syntax")
};
assert_eq!(args.limit, 2);
assert_eq!(args.pause_file, Some(PathBuf::from("held")));
}
}