1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! Explicit CLI UX: named-device speech remains a host operation; synthesis
//! can instead return audio to Drive without playing anything on the host.
use super::device::{Device, DEFAULT_DAEMON};
use super::synthesis::{ModelSources, Synthesizer};
use super::{Channel, Voice};
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
version = crate::GIT_VERSION,
name = "voice",
about = "Speech synthesis + privacy-aware output routing, on two channels."
)]
pub struct Cli {
/// Path to the pile file
#[arg(long, env = "PILE")]
pile: PathBuf,
/// Existing durable signing-key file. Reads and writes never create it;
/// initialize explicitly with `trible pile signing-key init <pile>`.
#[arg(long, env = "TRIBLESPACE_KEY")]
key: Option<PathBuf>,
/// Reachy daemon base URL (the `shout` Reachy-speaker target).
#[arg(long, env = "REACHY_DAEMON", default_value = DEFAULT_DAEMON)]
daemon: String,
/// Soma base URL. When explicitly configured and reachable, this is the
/// preferred drained-stream target for public `shout`; it is never used by
/// private `say`.
#[arg(long, env = "SOMA_URL", global = true)]
soma: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_accepts_drive_argument_order() {
let cli = Cli::try_parse_from([
"voice",
"--pile",
"voice-test.pile",
"stream",
"--soma",
"http://body:8383",
])
.unwrap();
assert_eq!(cli.soma.as_deref(), Some("http://body:8383"));
assert!(matches!(cli.command, Some(Command::Stream { .. })));
}
}
#[derive(Subcommand)]
enum Command {
/// Speak on the PRIVATE channel — in-ear / headphone only. Routes to the
/// highest-priority connected private device; if none can be safely
/// targeted, prints the text instead of playing aloud. Recorded on the
/// fixed Voice collection.
Say {
/// What to say.
text: String,
/// Resolve routing and report the target (or text-fallback) WITHOUT
/// synthesizing or playing — for checking the policy on a busy GPU.
#[arg(long)]
dry_run: bool,
/// Half-duplex pause file: created before any sound and removed when
/// the utterance ends, so a listener started with the SAME path drops
/// audio while we speak. See `crate::turntaking`. The listener
/// never closes its microphone — the hold stops the model, never the
/// person.
#[arg(long, env = "VOICE_PAUSE_FILE")]
pause_file: Option<PathBuf>,
},
/// Speak ALOUD on the PUBLIC channel — Soma body first, then Reachy daemon
/// and audible host-device fallbacks. Recorded on the fixed Voice
/// collection.
Shout {
/// What to shout.
text: String,
/// Resolve routing and report the target WITHOUT synthesizing/playing.
#[arg(long)]
dry_run: bool,
/// Half-duplex pause file — see `voice say --pause-file`. A shout is
/// exactly the case that needs it: the room speaker is in the room
/// with the microphone.
#[arg(long, env = "VOICE_PAUSE_FILE")]
pause_file: Option<PathBuf>,
},
/// Speak a framed text stream from stdin as it arrives. Complete sentences
/// become 24 kHz mono PCM records on stdout and, for public `shout`, stream
/// to a configured Soma body through the same resident Mary session.
Stream {
/// Channel under which completed sentences are recorded.
#[arg(long, default_value = "shout")]
channel: String,
/// Half-duplex pause file, held for the life of the stream.
#[arg(long, env = "VOICE_PAUSE_FILE")]
pause_file: Option<PathBuf>,
},
/// Show the routing policy for both channels, the connected audio devices,
/// and what each channel WOULD select right now (a pure dry-run). Read-only.
Route,
/// Set the ordered device-preference list for a channel, replacing it.
/// Devices are matched case-insensitively as substrings of the connected
/// device names. For `say`, non-private entries are warned about and will be
/// ignored at speak-time (the privacy invariant can't be configured away).
RouteSet {
/// "say" or "shout".
channel: String,
/// Device-name patterns in priority order (highest preference first).
#[arg(required = true)]
devices: Vec<String>,
},
/// Synthesize a resident WAV without playing any local device.
Synthesize {
/// Literal prose, @file, @- stdin, or @@escaped leading at-sign.
text: String,
/// Also save the original WAV here (recommended without Drive).
#[arg(long)]
out: Option<PathBuf>,
},
/// List the connected audio output devices and their privacy class. The
/// raw input to routing — a quick way to see what `say`/`shout` can target.
Devices,
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
if cli.command.is_none() {
Cli::command().print_help()?;
println!();
return Ok(());
}
if matches!(&cli.command, Some(Command::Stream { .. })) {
return execute_stream(cli);
}
crate::cli::with_output("voice", |out| execute(cli, out))
}
fn execute_stream(cli: Cli) -> Result<()> {
let Cli {
pile,
key,
soma,
command,
..
} = cli;
let Some(Command::Stream {
channel,
pause_file,
}) = command
else {
anyhow::bail!("Voice framed execution requires the stream command");
};
let channel = Channel::parse(&channel)?;
let _pause = pause_file.as_deref().map(|path| {
eprintln!(" [half-duplex] holding {}", path.display());
crate::turntaking::PauseGuard::hold(path)
});
let storage = crate::storage::Storage::shared(pile, key);
let voice = Voice::with_storage(storage.clone());
let synthesizer = Synthesizer::new(ModelSources::from_environment());
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let result = super::stream::run(
&voice,
&synthesizer,
channel,
soma.as_deref(),
stdin.lock(),
stdout.lock(),
)
.map(|_| ());
storage.finish(result)
}
pub fn execute(cli: Cli, out: &mut crate::out::Out<'_>) -> Result<()> {
let voice = Voice::new(cli.pile, cli.key);
let synthesizer = Synthesizer::new(ModelSources::from_environment());
let device = Device::new(voice.clone(), cli.daemon, cli.soma, synthesizer.clone());
match cli.command {
None => anyhow::bail!("Voice requires an explicit command"),
Some(Command::Say {
text,
dry_run,
pause_file,
}) => {
let text = crate::text_arg(&text, "voice text")?;
device
.speak(Channel::Say, &text, dry_run, pause_file.as_deref(), out)?
.emit(Channel::Say, out)
}
Some(Command::Shout {
text,
dry_run,
pause_file,
}) => {
let text = crate::text_arg(&text, "voice text")?;
device
.speak(Channel::Shout, &text, dry_run, pause_file.as_deref(), out)?
.emit(Channel::Shout, out)
}
Some(Command::Stream { .. }) => {
anyhow::bail!("voice stream writes framed PCM and must bypass ordinary CLI output")
}
Some(Command::Route) => device.routes()?.emit(out),
Some(Command::RouteSet { channel, devices }) => voice
.set_route(Channel::parse(&channel)?, &devices)?
.emit(out),
Some(Command::Devices) => {
let devices = super::device::detect_output_devices()?;
if devices.is_empty() {
out.line("no audio output devices found.")?;
}
for device in devices {
out.line(super::device::describe_device(&device))?;
}
Ok(())
}
Some(Command::Synthesize { text, out: path }) => {
anyhow::ensure!(
cfg!(feature = "voice"),
"speech synthesis requires a build with the `voice` feature"
);
let text = crate::text_arg(&text, "voice text")?;
let clip = synthesizer.synthesize(&text)?;
if let Some(path) = path {
clip.save(&path)?;
out.line(path.display().to_string())?;
}
out.audio(clip.wav, super::AUDIO_WAV_MIME)
}
}
}