Files
codex/codex-rs/tui/src/voice.rs
T
Eric Traut f2bc2f26a9 Remove core protocol dependency [2/2] (#20325)
## Why

With the local model layer and app-server routing in place from PR1,
this PR moves the active TUI runtime onto app-server notifications. The
affected pieces share the same event flow, so the command surface,
session state, bottom-pane prompts, chat rendering, history/status
views, and tests move together to keep the stacked branch buildable.

This PR also removes the obsolete compatibility surface that is no
longer used after the migration. The proposed protocol-boundary verifier
layer was dropped from the stack; enforcing that final boundary will be
simpler once `codex-tui` no longer needs any `codex_protocol`
references.

This PR is part 2 of a 2-PR stack:

1. Add TUI-owned replacement models and extract app-server event
routing.
2. Move the active TUI flow to app-server notifications and delete
obsolete adapter code.

## What changed

- Rewired app command and session handling to use app-server request and
notification shapes.
- Moved approval overlays, request-user-input flows, MCP elicitation,
realtime events, and review commands onto the app-server-facing model
surface.
- Updated chat rendering, history cells, status views, multi-agent UI,
replay state, and TUI tests to use app-server notifications plus the
local models introduced in PR1.
- Deleted `codex-rs/tui/src/app/app_server_adapter.rs` and the
superseded `chatwidget/tests/background_events.rs` fixture path.

## Verification

- `cargo check -p codex-tui --tests`
- Top of stack: `cargo test -p codex-tui`
2026-04-30 11:34:34 -07:00

487 lines
15 KiB
Rust

use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::Config;
use base64::Engine;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use cpal::traits::DeviceTrait;
use cpal::traits::StreamTrait;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
use std::sync::atomic::Ordering;
use tracing::error;
const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000;
const MODEL_AUDIO_CHANNELS: u16 = 1;
pub struct VoiceCapture {
stream: Option<cpal::Stream>,
stopped: Arc<AtomicBool>,
last_peak: Arc<AtomicU16>,
}
impl VoiceCapture {
pub fn start_realtime(config: &Config, tx: AppEventSender) -> Result<Self, String> {
let (device, config) = select_realtime_input_device_and_config(config)?;
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let stopped = Arc::new(AtomicBool::new(false));
let last_peak = Arc::new(AtomicU16::new(0));
let stream = build_realtime_input_stream(
&device,
&config,
sample_rate,
channels,
tx,
last_peak.clone(),
)?;
stream
.play()
.map_err(|e| format!("failed to start input stream: {e}"))?;
Ok(Self {
stream: Some(stream),
stopped,
last_peak,
})
}
pub fn stop(mut self) {
// Mark stopped so any metering task can exit cleanly.
self.stopped.store(true, Ordering::SeqCst);
// Dropping the stream stops capture.
self.stream.take();
}
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
self.stopped.clone()
}
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
self.last_peak.clone()
}
}
pub(crate) struct RecordingMeterState {
history: VecDeque<char>,
noise_ema: f64,
env: f64,
}
impl RecordingMeterState {
pub(crate) fn new() -> Self {
let mut history = VecDeque::with_capacity(4);
while history.len() < 4 {
history.push_back('⠤');
}
Self {
history,
noise_ema: 0.02,
env: 0.0,
}
}
pub(crate) fn next_text(&mut self, peak: u16) -> String {
const SYMBOLS: [char; 7] = ['⠤', '⠴', '⠶', '⠷', '⡷', '⡿', '⣿'];
const ALPHA_NOISE: f64 = 0.05;
const ATTACK: f64 = 0.80;
const RELEASE: f64 = 0.25;
let latest_peak = peak as f64 / (i16::MAX as f64);
if latest_peak > self.env {
self.env = ATTACK * latest_peak + (1.0 - ATTACK) * self.env;
} else {
self.env = RELEASE * latest_peak + (1.0 - RELEASE) * self.env;
}
let rms_approx = self.env * 0.7;
self.noise_ema = (1.0 - ALPHA_NOISE) * self.noise_ema + ALPHA_NOISE * rms_approx;
let ref_level = self.noise_ema.max(0.01);
let fast_signal = 0.8 * latest_peak + 0.2 * self.env;
let target = 2.0f64;
let raw = (fast_signal / (ref_level * target)).max(0.0);
let k = 1.6f64;
let compressed = (raw.ln_1p() / k.ln_1p()).min(1.0);
let idx = (compressed * (SYMBOLS.len() as f64 - 1.0))
.round()
.clamp(0.0, SYMBOLS.len() as f64 - 1.0) as usize;
let level_char = SYMBOLS[idx];
if self.history.len() >= 4 {
self.history.pop_front();
}
self.history.push_back(level_char);
let mut text = String::with_capacity(4);
for ch in &self.history {
text.push(*ch);
}
text
}
}
// -------------------------
// Voice input helpers
// -------------------------
fn select_realtime_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
crate::audio_device::select_configured_input_device_and_config(config)
}
fn build_realtime_input_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
sample_rate: u32,
channels: u16,
tx: AppEventSender,
last_peak: Arc<AtomicU16>,
) -> Result<cpal::Stream, String> {
match config.sample_format() {
cpal::SampleFormat::F32 => device
.build_input_stream(
&config.clone().into(),
move |input: &[f32], _| {
let peak = peak_f32(input);
last_peak.store(peak, Ordering::Relaxed);
let samples = input.iter().copied().map(f32_to_i16).collect::<Vec<_>>();
send_realtime_audio_chunk(&tx, samples, sample_rate, channels);
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
cpal::SampleFormat::I16 => device
.build_input_stream(
&config.clone().into(),
move |input: &[i16], _| {
let peak = peak_i16(input);
last_peak.store(peak, Ordering::Relaxed);
send_realtime_audio_chunk(&tx, input.to_vec(), sample_rate, channels);
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
cpal::SampleFormat::U16 => device
.build_input_stream(
&config.clone().into(),
move |input: &[u16], _| {
let mut samples = Vec::with_capacity(input.len());
let peak = convert_u16_to_i16_and_peak(input, &mut samples);
last_peak.store(peak, Ordering::Relaxed);
send_realtime_audio_chunk(&tx, samples, sample_rate, channels);
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
_ => Err("unsupported input sample format".to_string()),
}
}
fn send_realtime_audio_chunk(
tx: &AppEventSender,
samples: Vec<i16>,
sample_rate: u32,
channels: u16,
) {
if samples.is_empty() || sample_rate == 0 || channels == 0 {
return;
}
let samples = if sample_rate == MODEL_AUDIO_SAMPLE_RATE && channels == MODEL_AUDIO_CHANNELS {
samples
} else {
convert_pcm16(
&samples,
sample_rate,
channels,
MODEL_AUDIO_SAMPLE_RATE,
MODEL_AUDIO_CHANNELS,
)
};
if samples.is_empty() {
return;
}
let mut bytes = Vec::with_capacity(samples.len() * 2);
for sample in &samples {
bytes.extend_from_slice(&sample.to_le_bytes());
}
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let samples_per_channel = (samples.len() / usize::from(MODEL_AUDIO_CHANNELS)) as u32;
tx.realtime_conversation_audio(ThreadRealtimeAudioChunk {
data: encoded,
sample_rate: MODEL_AUDIO_SAMPLE_RATE,
num_channels: MODEL_AUDIO_CHANNELS,
samples_per_channel: Some(samples_per_channel),
item_id: None,
});
}
#[inline]
fn f32_abs_to_u16(x: f32) -> u16 {
let peak_u = (x.abs().min(1.0) * i16::MAX as f32) as i32;
peak_u.max(0) as u16
}
#[inline]
fn f32_to_i16(s: f32) -> i16 {
(s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
fn peak_f32(input: &[f32]) -> u16 {
let mut peak: f32 = 0.0;
for &s in input {
let a = s.abs();
if a > peak {
peak = a;
}
}
f32_abs_to_u16(peak)
}
fn peak_i16(input: &[i16]) -> u16 {
let mut peak: i32 = 0;
for &s in input {
let a = (s as i32).unsigned_abs() as i32;
if a > peak {
peak = a;
}
}
peak as u16
}
fn convert_u16_to_i16_and_peak(input: &[u16], out: &mut Vec<i16>) -> u16 {
let mut peak: i32 = 0;
for &s in input {
let v_i16 = (s as i32 - 32768) as i16;
let a = (v_i16 as i32).unsigned_abs() as i32;
if a > peak {
peak = a;
}
out.push(v_i16);
}
peak as u16
}
// -------------------------
// Realtime audio playback helpers
// -------------------------
pub(crate) struct RealtimeAudioPlayer {
_stream: cpal::Stream,
queue: Arc<Mutex<VecDeque<i16>>>,
output_sample_rate: u32,
output_channels: u16,
}
impl RealtimeAudioPlayer {
pub(crate) fn start(config: &Config) -> Result<Self, String> {
let (device, config) =
crate::audio_device::select_configured_output_device_and_config(config)?;
let output_sample_rate = config.sample_rate().0;
let output_channels = config.channels();
let queue = Arc::new(Mutex::new(VecDeque::new()));
let stream = build_output_stream(&device, &config, Arc::clone(&queue))?;
stream
.play()
.map_err(|e| format!("failed to start output stream: {e}"))?;
Ok(Self {
_stream: stream,
queue,
output_sample_rate,
output_channels,
})
}
pub(crate) fn enqueue_frame(&self, frame: &ThreadRealtimeAudioChunk) -> Result<(), String> {
if frame.num_channels == 0 || frame.sample_rate == 0 {
return Err("invalid realtime audio frame format".to_string());
}
let raw_bytes = base64::engine::general_purpose::STANDARD
.decode(&frame.data)
.map_err(|e| format!("failed to decode realtime audio: {e}"))?;
if raw_bytes.len() % 2 != 0 {
return Err("realtime audio frame had odd byte length".to_string());
}
let mut pcm = Vec::with_capacity(raw_bytes.len() / 2);
for pair in raw_bytes.chunks_exact(2) {
pcm.push(i16::from_le_bytes([pair[0], pair[1]]));
}
let converted = convert_pcm16(
&pcm,
frame.sample_rate,
frame.num_channels,
self.output_sample_rate,
self.output_channels,
);
if converted.is_empty() {
return Ok(());
}
let mut guard = self
.queue
.lock()
.map_err(|_| "failed to lock output audio queue".to_string())?;
// TODO(aibrahim): Cap or trim this queue if we observe producer bursts outrunning playback.
guard.extend(converted);
Ok(())
}
pub(crate) fn clear(&self) {
if let Ok(mut guard) = self.queue.lock() {
guard.clear();
}
}
}
fn build_output_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
queue: Arc<Mutex<VecDeque<i16>>>,
) -> Result<cpal::Stream, String> {
let config_any: cpal::StreamConfig = config.clone().into();
match config.sample_format() {
cpal::SampleFormat::F32 => device
.build_output_stream(
&config_any,
move |output: &mut [f32], _| fill_output_f32(output, &queue),
move |err| error!("audio output error: {err}"),
None,
)
.map_err(|e| format!("failed to build f32 output stream: {e}")),
cpal::SampleFormat::I16 => device
.build_output_stream(
&config_any,
move |output: &mut [i16], _| fill_output_i16(output, &queue),
move |err| error!("audio output error: {err}"),
None,
)
.map_err(|e| format!("failed to build i16 output stream: {e}")),
cpal::SampleFormat::U16 => device
.build_output_stream(
&config_any,
move |output: &mut [u16], _| fill_output_u16(output, &queue),
move |err| error!("audio output error: {err}"),
None,
)
.map_err(|e| format!("failed to build u16 output stream: {e}")),
other => Err(format!("unsupported output sample format: {other:?}")),
}
}
fn fill_output_i16(output: &mut [i16], queue: &Arc<Mutex<VecDeque<i16>>>) {
if let Ok(mut guard) = queue.lock() {
for sample in output {
*sample = guard.pop_front().unwrap_or(0);
}
return;
}
output.fill(0);
}
fn fill_output_f32(output: &mut [f32], queue: &Arc<Mutex<VecDeque<i16>>>) {
if let Ok(mut guard) = queue.lock() {
for sample in output {
let v = guard.pop_front().unwrap_or(0);
*sample = (v as f32) / (i16::MAX as f32);
}
return;
}
output.fill(0.0);
}
fn fill_output_u16(output: &mut [u16], queue: &Arc<Mutex<VecDeque<i16>>>) {
if let Ok(mut guard) = queue.lock() {
for sample in output {
let v = guard.pop_front().unwrap_or(0);
*sample = (v as i32 + 32768).clamp(0, u16::MAX as i32) as u16;
}
return;
}
output.fill(32768);
}
fn convert_pcm16(
input: &[i16],
input_sample_rate: u32,
input_channels: u16,
output_sample_rate: u32,
output_channels: u16,
) -> Vec<i16> {
if input.is_empty() || input_channels == 0 || output_channels == 0 {
return Vec::new();
}
let in_channels = input_channels as usize;
let out_channels = output_channels as usize;
let in_frames = input.len() / in_channels;
if in_frames == 0 {
return Vec::new();
}
let out_frames = if input_sample_rate == output_sample_rate {
in_frames
} else {
(((in_frames as u64) * (output_sample_rate as u64)) / (input_sample_rate as u64)).max(1)
as usize
};
let mut out = Vec::with_capacity(out_frames.saturating_mul(out_channels));
for out_frame_idx in 0..out_frames {
let src_frame_idx = if out_frames <= 1 || in_frames <= 1 {
0
} else {
((out_frame_idx as u64) * ((in_frames - 1) as u64) / ((out_frames - 1) as u64)) as usize
};
let src_start = src_frame_idx.saturating_mul(in_channels);
let src = &input[src_start..src_start + in_channels];
match (in_channels, out_channels) {
(1, 1) => out.push(src[0]),
(1, n) => {
for _ in 0..n {
out.push(src[0]);
}
}
(n, 1) if n >= 2 => {
let sum: i32 = src.iter().map(|s| *s as i32).sum();
out.push((sum / (n as i32)) as i16);
}
(n, m) if n == m => out.extend_from_slice(src),
(n, m) if n > m => out.extend_from_slice(&src[..m]),
(n, m) => {
out.extend_from_slice(src);
let last = *src.last().unwrap_or(&0);
for _ in n..m {
out.push(last);
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::convert_pcm16;
use pretty_assertions::assert_eq;
#[test]
fn convert_pcm16_downmixes_and_resamples_for_model_input() {
let input = vec![100, 300, 200, 400, 500, 700, 600, 800];
let converted = convert_pcm16(
&input, /*input_sample_rate*/ 48_000, /*input_channels*/ 2,
/*output_sample_rate*/ 24_000, /*output_channels*/ 1,
);
assert_eq!(converted, vec![200, 700]);
}
}