Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1d685d7
Add runtime voice status sounds
Sep 8, 2026
28dfba0
refactor(voice): tighten status sound boundaries
Sep 9, 2026
9b00c9a
fix(voice): downmix status sound assets
Sep 9, 2026
d666e00
refactor(voice): simplify status cue policy
Sep 9, 2026
b4815b9
fix(voice): isolate status cues from input
Sep 9, 2026
4c94f53
fix(voice): cover status cue output tail
Sep 9, 2026
c049a30
fix(voice): refine status cue input gating
Sep 9, 2026
1174531
fix(voice): construct platform status player directly
Sep 9, 2026
6728e5f
fix(voice): integrate runtime status sounds
Sep 9, 2026
d1278da
Fix status sound runtime lifecycle
Sep 9, 2026
53b505a
Make status sound exports rustfmt-stable
Sep 9, 2026
1930da0
Simplify continuous voice status sounds
Sep 10, 2026
cd5f802
Derive status sound settings defaults
Sep 10, 2026
449111c
Resume status cues after voice activity
Sep 10, 2026
aa0d943
docs(voice): correct status sound default volume
johnmatthewtennant Sep 11, 2026
43cb037
fix(voice): preserve legacy status sound modes
johnmatthewtennant Sep 11, 2026
df408fc
fix(voice): stabilize status sound formatting
johnmatthewtennant Sep 11, 2026
eda164f
fix(voice): separate run status from playback suppression
johnmatthewtennant Sep 11, 2026
52c99ae
fix(voice): validate session status sound volume
johnmatthewtennant Sep 11, 2026
4e0d454
fix(voice): retain status cue suppression transitions
johnmatthewtennant Sep 11, 2026
81c5a65
fix(voice): join managed status sound workers on shutdown
johnmatthewtennant Sep 11, 2026
c0a817e
fix(voice): suppress speaker cue feedback into native input
johnmatthewtennant Sep 11, 2026
aacad55
style(voice): format status sound crate for cross-platform checks
johnmatthewtennant Sep 11, 2026
7b21001
feat(voice): add off mode and delay status cues to window end
johnmatthewtennant Sep 11, 2026
fbe8bc6
docs(voice): align status cue protocol with quiet windows
johnmatthewtennant Sep 11, 2026
259f141
fix(voice): keep CLI status cues active during recognition
johnmatthewtennant Sep 11, 2026
d8879d9
fix(voice): start working cues on the first live tool call
johnmatthewtennant Sep 11, 2026
a2228b4
fix(voice): silence waiting cues while a prompt awaits tools
johnmatthewtennant Sep 11, 2026
2fe690e
test(voice): name immediate status cue invariant
johnmatthewtennant Sep 11, 2026
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
31 changes: 22 additions & 9 deletions src-tauri/crates/berd-voice/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
`berd-voice session` is a development, full-authority voice session. The child
owns speech recognition, finalized-input order, confirmation, speak admission,
synthesis, source-frame delivery, playback lifecycle, and barge-in. The parent
owns capture and playback devices: it writes normalized microphone PCM on stdin
and consumes synthesized PCM from a dedicated inherited pipe. The child writes
owns capture and conversational playback devices: it writes normalized microphone PCM on
stdin and consumes synthesized PCM from a dedicated inherited pipe. Status cues are
the narrow exception: the child plays them on the output-device name supplied by the
parent, or the system default when none is supplied. The child writes
flushed JSONL events to stdout. Diagnostics go only to stderr.

## Startup
Expand Down Expand Up @@ -44,17 +46,19 @@ remain terminal speech events.
has no device-owning or stdout-multiplexed fallback.

The first request must be `hello`. `input_during_tts` is the host's resolved
initial policy; a host-specific `auto` mode must be resolved before the request:
initial policy; a host-specific `auto` mode must be resolved before the request.
`status_sound_output_device` is the host's selected playback-device name; omitting it uses the system default:

```json
{"type":"hello","id":1,"input_during_tts":"allow_barge_in"}
{"type":"hello","id":1,"input_during_tts":"allow_barge_in","status_sound_output_device":"MacBook Pro Speakers"}
```

The response uses `protocol:4` as the exact session message-set version. The
parent must reject a version it does not support:
The response uses `protocol:5` as the exact session message-set version. The
parent must reject a version it does not support; the JSON version is independent
of the fixed binary framing marker described below:

```json
{"type":"ready","id":1,"protocol":4,"session":{"tts":{"revision":1,"backend":"siri","voice":"Aaron","language":"en-US","rate":1.0},"input_during_tts":{"revision":1,"policy":"allow_barge_in"}}}
{"type":"ready","id":1,"protocol":5,"session":{"tts":{"revision":1,"backend":"siri","voice":"Aaron","language":"en-US","rate":1.0},"input_during_tts":{"revision":1,"policy":"allow_barge_in"}}}
```

The `session.tts` object is the authoritative, sanitized TTS configuration.
Expand All @@ -64,7 +68,9 @@ and `rate`. Credentials, endpoints, and bundle paths never appear on stdout.
Detailed backend errors are diagnostics on stderr only; protocol rejection and
fatal messages are sanitized at the stdout boundary.
`session.input_during_tts` is the authoritative effective assistant-input
policy and has its own revision.
policy and has its own revision. Status-sound settings are per-update parameters,
not session snapshot configuration. The runtime owns cue cadence and playback; it
does not persist preferences.

## Stdin framing

Expand Down Expand Up @@ -179,9 +185,10 @@ it never admits a replacement while old host audio may still be active.
## Parent requests

```text
{"type":"hello","id":u64,"input_during_tts":"allow_barge_in"|"suppress_input"}
{"type":"hello","id":u64,"input_during_tts":"allow_barge_in"|"suppress_input","status_sound_output_device":string?}
{"type":"set_paused","active":bool}
{"type":"set_input_muted","id":u64,"active":bool}
{"type":"set_conversation_status","id":u64,"status":"working"|"waiting","settings":StatusSoundSettings}
{"type":"set_tts_settings","id":u64,"expected_revision":u64,"settings":TtsSettings}
{"type":"set_input_during_tts","id":u64,"expected_revision":u64,"policy":"allow_barge_in"|"suppress_input"}
{"type":"reset_input","id":u64}
Expand All @@ -198,6 +205,12 @@ Unknown fields are rejected. IDs are positive. Speak text is at most 16 KiB.
The parent cannot author speaking state or finalized input; those are derived
only from PCM by the child runtime.

`StatusSoundSettings` has a `mode` of `off`, `working`, or `working-and-waiting` and an optional per-session `volume` from 0 to 1 that defaults to `0.8`. No cue is emitted until the first `set_conversation_status` request. Cues play immediately on status transitions and repeat every five seconds; repeated identical updates preserve the cadence. After conversation audio ends, the current cue resumes immediately. `off` disables both cues; `working` repeats only the working cue and stays silent while waiting; `working-and-waiting` repeats the current working or waiting cue. Active user speech or assistant output suppresses playback without changing the working/waiting state. Pending recognition alone does not suppress playback. On macOS, working uses the system Pop sound and waiting uses Purr through the selected output device. The applied request is acknowledged with:

```text
{"type":"conversation_status_applied","id":u64,"status":"working"|"waiting","settings":StatusSoundSettings}
```

`set_tts_settings` accepts the same tagged public object projected by `ready`,
without `revision`. It changes settings only for the already-active backend:

Expand Down
8 changes: 6 additions & 2 deletions src-tauri/crates/berd-voice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ Siri bridge emits normalized 48 kHz mono Float32 PCM without opening an audio
device; the existing Berd Siri player and the CLI use the same decoder.

`berd-voice session` exposes the development voice-session protocol documented
in [PROTOCOL.md](PROTOCOL.md). Siri TTS and macOS speech recognition are the
defaults:
in [PROTOCOL.md](PROTOCOL.md). The host supplies persisted status-sound settings
and semantic `working` / `waiting` updates through that protocol; the runtime
owns the five-second cadence, speech suppression, and macOS Pop/Purr playback.
The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. When conversation audio ends, the current cue resumes immediately. Repeated identical status updates preserve the cadence. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends.

Siri TTS and macOS speech recognition are the defaults:

```text
berd-voice session --voice Aaron --language en-US --rate 1.0
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/crates/berd-voice/native/siri_tts_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ bool berd_siri_tts_speak(
char **error_out
);

/// Decodes an audio file through AVFoundation into malloc-owned mono Float32 PCM.
/// The caller releases successful samples with `berd_audio_free_samples`.
float *berd_audio_file_load_mono_pcm(
const char *path,
uint32_t *sample_rate_out,
uint32_t *frame_count_out,
char **error_out
);
void berd_audio_free_samples(float *samples);

/// Opaque Pocket PCM player backed by AVAudioUnitTimePitch. Samples are
/// mono, noninterleaved float PCM. Device ID 0 uses the system default.
void *berd_pocket_audio_player_create(
Expand Down
93 changes: 93 additions & 0 deletions src-tauri/crates/berd-voice/native/siri_tts_bridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,99 @@ bool berd_siri_tts_speak(
}
}

float *berd_audio_file_load_mono_pcm(
const char *pathValue,
uint32_t *sampleRateOut,
uint32_t *frameCountOut,
char **errorOut
) {
@autoreleasepool {
if (errorOut) *errorOut = NULL;
if (!pathValue || !sampleRateOut || !frameCountOut) {
BerdSetError(errorOut, BerdError(38, @"An audio path and output pointers are required."));
return NULL;
}
NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:pathValue]];
ExtAudioFileRef file = NULL;
OSStatus status = ExtAudioFileOpenURL((__bridge CFURLRef)url, &file);
if (status != noErr || !file) {
BerdSetError(errorOut, BerdError(39, @"Could not open the audio file."));
return NULL;
}
AudioStreamBasicDescription sourceFormat = {0};
UInt32 propertySize = sizeof(sourceFormat);
status = ExtAudioFileGetProperty(
file, kExtAudioFileProperty_FileDataFormat, &propertySize, &sourceFormat);
SInt64 sourceFrames = 0;
propertySize = sizeof(sourceFrames);
if (status == noErr) {
status = ExtAudioFileGetProperty(
file, kExtAudioFileProperty_FileLengthFrames, &propertySize, &sourceFrames);
}
AudioStreamBasicDescription clientFormat = {0};
clientFormat.mSampleRate = sourceFormat.mSampleRate;
clientFormat.mFormatID = kAudioFormatLinearPCM;
clientFormat.mFormatFlags = kAudioFormatFlagsNativeFloatPacked;
uint32_t channelCount = sourceFormat.mChannelsPerFrame;
clientFormat.mBytesPerPacket = sizeof(float) * channelCount;
clientFormat.mFramesPerPacket = 1;
clientFormat.mBytesPerFrame = sizeof(float) * channelCount;
clientFormat.mChannelsPerFrame = channelCount;
clientFormat.mBitsPerChannel = 8 * sizeof(float);
if (status == noErr) {
status = ExtAudioFileSetProperty(
file, kExtAudioFileProperty_ClientDataFormat,
sizeof(clientFormat), &clientFormat);
}
if (status != noErr || sourceFrames <= 0 || sourceFrames > UINT32_MAX) {
ExtAudioFileDispose(file);
BerdSetError(errorOut, BerdError(40, @"Could not prepare the audio file for decoding."));
return NULL;
}
uint32_t capacity = (uint32_t)sourceFrames;
float *samples = malloc((size_t)capacity * channelCount * sizeof(float));
if (!samples) {
ExtAudioFileDispose(file);
BerdSetError(errorOut, BerdError(41, @"Could not allocate decoded audio samples."));
return NULL;
}
uint32_t frameCount = 0;
while (frameCount < capacity) {
UInt32 requestedFrames = capacity - frameCount;
AudioBufferList buffers = {0};
buffers.mNumberBuffers = 1;
buffers.mBuffers[0].mNumberChannels = channelCount;
buffers.mBuffers[0].mDataByteSize = requestedFrames * channelCount * sizeof(float);
buffers.mBuffers[0].mData = samples + ((size_t)frameCount * channelCount);
status = ExtAudioFileRead(file, &requestedFrames, &buffers);
if (status != noErr || requestedFrames == 0) break;
frameCount += requestedFrames;
}
ExtAudioFileDispose(file);
if (status != noErr || frameCount != capacity) {
free(samples);
BerdSetError(errorOut, BerdError(42, @"Could not decode the audio file."));
return NULL;
}
if (channelCount > 1) {
for (uint32_t frame = 0; frame < frameCount; frame++) {
float mixed = 0;
for (uint32_t channel = 0; channel < channelCount; channel++) {
mixed += samples[frame * channelCount + channel];
}
samples[frame] = mixed / channelCount;
}
}
*sampleRateOut = (uint32_t)clientFormat.mSampleRate;
*frameCountOut = frameCount;
return samples;
}
}

void berd_audio_free_samples(float *samples) {
free(samples);
}

void *berd_pocket_audio_player_create(
uint32_t sampleRate,
float rate,
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/crates/berd-voice/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod realtime_pipe;
pub mod session;
pub mod siri;
pub mod spokesperson_voice_update;
mod status_sounds;
mod synthesis;
mod tts;

Expand All @@ -54,6 +55,11 @@ pub use pocket::{
};
#[cfg(target_os = "macos")]
pub use siri::SiriTts;
pub use status_sounds::ConversationStatus;
pub use status_sounds::ManagedStatusSoundRuntime;
pub use status_sounds::StatusSoundMode;
pub use status_sounds::StatusSoundRuntime;
pub use status_sounds::StatusSoundSettings;
pub use synthesis::{synthesize_pcm16_wav, WavSynthesis, WavSynthesisError, WavSynthesisErrorKind};
pub use tts::{
OpenAiTts, PocketTtsBackend, StreamingTextChunk, StreamingTextChunks, StreamingTtsText,
Expand Down
33 changes: 32 additions & 1 deletion src-tauri/crates/berd-voice/src/macos_audio_output.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
//! Safe ownership wrapper for the shared macOS AVAudioUnitTimePitch PCM player.
//! Safe wrappers for the shared macOS audio FFI boundary.

use std::ffi::{c_char, c_void, CStr};

use crate::PcmAudioOutput;

unsafe extern "C" {
fn berd_audio_file_load_mono_pcm(
path: *const c_char,
sample_rate_out: *mut u32,
frame_count_out: *mut u32,
error_out: *mut *mut c_char,
) -> *mut f32;
fn berd_audio_free_samples(samples: *mut f32);
fn berd_pocket_audio_player_create(
sample_rate: u32,
rate: f32,
Expand All @@ -25,6 +32,30 @@ unsafe extern "C" {
fn berd_siri_tts_free_string(value: *mut c_char);
}

pub(crate) fn load_mono_audio_file(path: &str) -> Result<(u32, Vec<f32>), String> {
let path = std::ffi::CString::new(path).map_err(|_| "audio path contains NUL".to_string())?;
let mut sample_rate = 0;
let mut frame_count = 0;
let mut error = std::ptr::null_mut();
// SAFETY: The bridge copies the path and returns an owned allocation with
// the reported frame count, released below by its paired free function.
let raw = unsafe {
berd_audio_file_load_mono_pcm(
path.as_ptr(),
&mut sample_rate,
&mut frame_count,
&mut error,
)
};
if raw.is_null() {
return Err(take_error(error, "Could not decode audio file"));
}
// SAFETY: A successful bridge call returns exactly `frame_count` initialized samples.
let samples = unsafe { std::slice::from_raw_parts(raw, frame_count as usize) }.to_vec();
unsafe { berd_audio_free_samples(raw) };
Ok((sample_rate, samples))
}

pub struct PocketAudioPlayer {
raw: *mut c_void,
delivery_safety_frames: u64,
Expand Down
Loading
Loading