Skip to content

Fix review findings, update OpenAI models, release 0.6.0 - #11

Merged
d6e merged 99 commits into
mainfrom
review-fixes
Sep 29, 2026
Merged

d6e merged 99 commits into
mainfrom
review-fixes

Conversation

@d6e

@d6e d6e commented Sep 29, 2026

Copy link
Copy Markdown
Owner

This branch fixes the findings of a full review of BabbleBoop, updates the OpenAI models, and bumps the version to 0.6.0. CI was red on main (fmt, clippy on Rust 1.97, and a test that did not compile); this branch makes it green again.

What changes for users

  • Models. New installs use gpt-6-luna for translation and gpt-transcribe for transcription. The model fields accept any name. The presets drop gpt-4, gpt-4-turbo and gpt-3.5-turbo, which OpenAI shuts down on 2026-10-23. If the configured model has a shutdown date, the activity log shows the date and the replacement. whisper-1 and the gpt-4o transcription models shut down on 2027-02-26.
  • Silence setting. audio.silence_threshold (a count of audio buffers, so its length depended on the device) is now audio.silence_duration in seconds, default 1.0. An old config still loads; the log says the old key is not used, and Save writes the new key.
  • File locations. A config.toml in the working directory is used first, then one next to the executable. A new config goes next to the executable, or in the working directory if that folder cannot be written. total_cost.txt and recordings/ follow the config. The log shows the folder at startup.
  • Config values. Values that the program cannot use (NaN, out of range, blank strings, max_audio_files = 0) are replaced when the file loads, and each replacement is logged. The file changes only when you click Save.
  • Long speech. Speech longer than 30 s is sent in 30 s parts. Uploads are 16 kHz mono 16 bit WAV, about 1 MB for 30 s, so they stay far below the 25 MB limit.
  • Chatbox. The pause between the chunks of one message stays. After the last chunk, the next message waits only for the rest of display_time, so the next utterance is processed without a fixed 3 s delay.
  • Errors in the activity log. Startup failures, audio input and playback errors, crashes of the processing thread, failed cost saves, failed typing indicator sends, and refused or filtered translations now appear in the log. A translation that the model refused, that the content filter cut, or that reached the token limit is not sent to the chatbox.
  • Cost. Translation cost uses the token counts that the API reports, including reasoning tokens. A request that the API bills is costed even when no translation is sent.
  • Test Microphone. The test recording stops after 30 s, and playback plays at the correct speed on any output device.
  • GUI. The window no longer redraws every frame. Light mode colours are fixed for the meters and the toggle.

Other fixes

  • Crashes: byte slicing of non-ASCII text (clippy string_slice is now denied), unwraps on file metadata, and panicking Duration conversions (banned in clippy.toml).
  • Closing the window now stops work in progress, including HTTP requests (10 s connect, 120 s request timeouts), the chatbox pause, and a stuck audio device at startup (15 s).
  • The cpal input callback no longer encodes WAV, logs, or waits on locks.
  • CI runs clippy with --all-targets.

Not verified

  • A live API call. The key I used has no credits left, so the request shape for gpt-6-luna (a developer message and reasoning_effort: "none") is checked only against the API reference.
  • On Windows: unplugging a USB microphone during speech, input glitches when the buffer grows during speech longer than 5 s, and a shortcut with another start folder.
  • In VRChat: the typing indicator can now turn on while the previous message still shows.

Tests: 207 unit tests, up from 9. Every commit passes cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test.

d6e added 30 commits September 28, 2026 06:29
The gate (cargo fmt --check, cargo clippy --all-targets -D warnings,
cargo test) failed on the current toolchain in three ways.

- Formatting: app_state.rs, audio_processing.rs and gui.rs are brought
  in line with rustfmt so the CI format check passes. No behaviour
  change.
- The Config initializer in test_config_round_trip did not set the
  theme field, so the test crate did not compile. Set theme, and give
  every field with a serde default a non default value, so a field that
  is not saved cannot pass by loading its default. Compare the loaded
  config with the whole original struct instead of a subset of fields.
  The previous assertions did not check several fields (for example
  max_message_chunks and transcription_model). Checked: with theme
  marked skip_serializing, the test fails.
- Clippy: gui.rs gives the threshold line stroke width an explicit f32
  type. Stroke::new takes impl Into<f32>, so the bare 2.0 literal
  triggers the float_literal_f32_fallback future incompatibility
  warning, which -D warnings makes an error. transcription.rs removes
  the redundant borrow in the Authorization header format! call
  (useless_borrows_in_formatting). No behaviour change.
CI ran `cargo clippy -- -D warnings`, which checks only the library and
binary targets. Add `--all-targets` so tests are linted too, matching
the local gate.
`parse_api_error_for_display` cut messages over 120 bytes at byte 117.
When byte 117 was inside a multibyte character, the slice panicked and
took down the processing thread. Count and take characters instead.
ASCII messages show the same text as before.

The test was run red first (panic at app_state.rs:112, "end byte index
117 is not a char boundary") and is committed with the fix so the
commit passes the gate.
`save_recording` built the file name from the first 50 bytes of the
transcription. When byte 50 was inside a multibyte character (accented
or CJK speech), the slice panicked and took down the processing thread.
This only ran with keep_audio_files on. Take the first 50 characters
instead. ASCII transcriptions give the same file names as before.

The test was run red first (panic at recording_manager.rs:30, "end byte
index 50 is not a char boundary") and is committed with the fix so the
commit passes the gate.
`cleanup_old_recordings` sorted directory entries by a key that
unwrapped `metadata()` and `modified()`. If either failed (a file
removed after read_dir listed it, or no search permission on the
directory), the unwrap panicked the processing thread. Read the
modification time once per entry and skip entries without one. They
cannot be ordered by age, so they are kept rather than deleted.

The test lives in recording_manager.rs because the function is
private. It uses a directory with read but no search permission, which
lists entries but blocks their metadata. It needs two entries, because
sort_by_key does not call the key for a single element. As root the
permissions do not block metadata, so the test reports a skip.

The test was run red first (unwrap panic at recording_manager.rs:48)
and is committed with the fix so the commit passes the gate.
Two processing thread panics came from slicing a str at a fixed byte
index. Deny `clippy::string_slice` for the package so the gate rejects
the construct. The lint is type aware, so it flags str slicing without
flagging slice indexing on sample buffers, and it also sees slices
inside format! arguments.

The last hit, the JSON start in `parse_api_error_for_display`, could
not panic because `find` returns a char boundary. It now uses
`str::get` so no allow is needed.

Checked by putting the old byte slice back in `save_recording`: clippy
fails with "indexing into a string may panic".
Each transcription and translation built a new reqwest::Client, so no
connection was reused and no request had a timeout. A stalled
connection held the processing loop forever.

The processing loop now builds one client and passes it to
transcribe_audio and ask_chatgpt.

Timeouts:
- Connect: 10 s. A TCP and TLS handshake to api.openai.com takes well
  under a second; 10 s allows for slow DNS and lossy links.
- Request: 120 s, from connect until the response body is read. The
  transcription upload is the longest request. The API limit is 25 MB;
  at a 2 Mbit/s uplink that is about 100 s, plus server processing.
  Current uploads are much smaller (about 65 s of 48 kHz stereo float
  audio fits in 25 MB). A chat completion for one utterance returns in
  a few seconds, so one shared value does not delay the error for a
  stuck translation in a way that matters.

The test points the client at a local listener that accepts and never
responds, with a 200 ms request timeout, and expects a timeout error.
The processing loop awaited process_audio inline, so it saw Quit only
after transcription, translation and send_to_chatbox had finished.
send_to_chatbox pauses display_time after each chunk (up to 30 s for
each of 10 chunks), and RateLimiter::wait can sleep up to 60 s. main
joins the processing thread after the GUI closes, so the process stayed
alive for that time.

AppState.shutdown was an AtomicBool, which async code cannot await. It
is now a Shutdown type around a tokio watch channel. Sync code (the
audio thread) polls is_requested. Async code awaits requested, which
also resolves when the request came before the wait started. run_until
runs a future until it finishes or shutdown is requested, and drops it
in the second case.

The processing loop selects on shutdown first and runs process_audio
through run_until, so the chatbox pause, the rate limiter wait and the
HTTP requests all stop when the GUI exits. The per chunk pause itself
is unchanged.

main also requests shutdown after run_gui returns, so the processing
thread stops when the GUI fails to start. The runtime is shut down
with a 1 s limit, because a plain drop waits without a limit for
blocking tasks, and a cancelled request can leave a DNS lookup on one.

Tests use a paused tokio clock (test-util, dev only): a 90 s chatbox
send stops 1 s in when shutdown is requested, and a request made before
the wait is seen. The main loop wiring is not unit tested, because
run_processing_loop needs a real audio device.
StartRecording turns the typing indicator on, and process_audio turns
it off at the end. When shutdown stops processing between the two, or
the app quits while the user speaks, VRChat keeps showing "typing".
The processing loop now sends typing off after it exits.

No unit test: the call is in run_processing_loop, which needs a real
audio device. TypingIndicator::stop_typing itself is unchanged.
The translation prompt put the instructions and the transcription in
one user message. When the speaker asked a question, the model could
answer it instead of translating it.

ChatGptRequest::translation now builds the request: the instructions,
with the target language, go in a system message, and the transcription
alone is the user message. ask_chatgpt sends a prepared request.

The cost estimate counted the prompt length / 4. approx_input_tokens
keeps that approximation over the content of all messages.

Tests serialize the request and check the message roles and contents,
and check that the token estimate counts both messages.
The rate limiter, price estimator and recording manager of the processing
loop move into one struct in the library, with apply_config for the
UpdateConfig command. This makes the settings update testable without an
audio device. No behaviour change.
UpdateConfig did not touch the price estimator, so after a model or
transcription model change the cost used the old model's prices until a
restart. The estimator now switches prices in place. It is not rebuilt,
because PriceEstimator::new reloads the total from total_cost.txt and the
in memory total is the value the GUI shows.
UpdateConfig replaced the rate limiter on every save, so saving any
setting reset the count of requests made in the current minute. The limit
is now changed in place, and the requests already made still count.
When run_processing_loop returned an error, the message went to stderr
only. The GUI kept showing "Enabled", and after an audio input failure
the last log line was "Starting audio recording...", so the app looked
healthy while it did nothing. A panic of the processing thread was also
silent.

run_logging_failure runs the processing loop, catches a panic, and logs
an error or a panic in the activity log. Failure to create the tokio
runtime is logged too, instead of panicking the thread.

The startup errors now say which step failed (OSC port, audio input,
HTTP client). The HTTP client is built before the audio thread starts,
so that its failure does not leave audio capture running with nothing
to receive the events.
The loop ignores audio events while translation is off. If translation
was disabled after StartRecording turned the VRChat typing indicator on,
the StopRecording was ignored and VRChat kept showing "typing".

SetEnabled(false) now turns the indicator off. The other option, to
handle StopRecording while disabled, can still leave the indicator on:
the audio callback sends StopRecording with try_send and ignores a full
channel, and it sends none while test mode is active, and the indicator
stays on until the speaker stops talking. The GUI sends SetEnabled with
blocking_send after it stores the flag, so the command arrives and no
StartRecording handled after it can turn the indicator on again.
The GUI repaints every frame today, so it sees changes that other
threads make. The next commit stops that, and then a log entry or a new
total cost would stay hidden until the next mouse or keyboard input.

GuiWaker holds the egui context, which run_gui attaches when the window
starts. Logger wakes it after it adds an activity log entry, and
AppState::set_total_cost wakes it after it stores the cost. The
processing loop logs "Processing stopped" through the same Logger.
No change in behaviour. BabbleBoopApp::ui shows one frame, and
audio_settings_ui shows the body of the Audio Settings section. Tests
can run both on a headless egui::Context, which update cannot, because
it needs an eframe::Frame. The grid spacing and label width become
constants, as the extracted section uses them too.

rustfmt did not format the section body while it was inside the
central panel closure; as a method it is formatted now.
update ended with a repaint request whenever the activity log had
entries. The log is never empty after startup, so the GUI rendered
continuously, also while minimized, and used GPU time next to VRChat.

The GUI now repaints on input and when GuiWaker wakes it for a new log
entry or cost. A status message asks for one frame when it expires, so
that it is hidden after 3 seconds. The Audio Settings section asks for a
frame every 33 ms to keep the meters and the test button current,
because the audio thread does not wake the GUI. It does not ask while
the window is minimized, as eframe still runs those frames.

The tests run the GUI on a headless egui context and read the repaint
delay that it asks for.
The noise gate, silence and recording duration meters painted a dark
gray track and fixed fill colours, and the translation toggle used fixed
track colours. In light mode the tracks and the off toggle showed as
dark bars on the light window.

They now take AppColors, as the input level meter already does: the
track uses meter_background, the fills use meter_low, meter_medium and
meter_high, and the toggle uses toggle_off, toggle_on and toggle_knob,
which were defined but not used.

The tests paint the meters and the toggle in each theme and check that
the tracks are light on a light window and dark on a dark window. The
fill colours are not tested; they are only moved to the palette.
The tooltip said "Total API cost this session", but PriceEstimator
loads the total from total_cost.txt at startup and saves it after each
request, so the value includes earlier sessions. It is also an estimate:
the token counts are approximate.

Text only; no test.
The hover text said the threshold counts "consecutive silent samples".
The audio callback counts one for each input buffer after the noise gate
closes, so the time depends on the buffer length that the device uses.
The text now says that, and gives the usual length on Windows (10 ms, so
100 buffers is about 1 second).

The unit stays the same; a change to seconds changes the config file and
is deferred. Text only; no test.
PriceEstimator wrote the warning for a model without a known price to
stderr only, which a user of the GUI does not see. The next commit lets
the GUI take any model name, so this case becomes common.

PriceEstimator::unknown_pricing returns the warnings, and
ProcessingServices logs them in the activity log at startup and after
each settings save. set_models no longer prints.
The Model setting was a list of five presets, so a newer or fine-tuned
model could not be selected in the GUI, although the config and the API
request take any model name.

The setting is now a text field with the preset list next to it. A
preset from the list replaces the text. Save still refuses an empty
name. The presets are the same; a later stage updates them with the
prices. A model without a known price is reported in the activity log
since the previous commit.

The tests click and type into the headless GUI: one types a custom
name, one selects a preset from the list.
After the processing thread stopped, for example because the OSC port
was in use, the activity log showed the error but the status next to the
translation toggle still said "Enabled", and the error could scroll out
of the log.

The thread calls AppState::mark_processing_stopped when it ends, and
the GUI then shows "Processing stopped" in the error colour in place of
Enabled or Disabled, with a tooltip that says to restart and to look in
the activity log. The call wakes the GUI. At shutdown the flag is also
set, but the window is closing then.

The main.rs wiring has no test (binary code); the GUI state and the
wake up are tested.
Add src/models.rs, a table of known chat and transcription models, and
read the cost estimate prices from it. The prices are the Standard tier
prices on developers.openai.com/api/docs/pricing, checked 2026-09-28.

New models: gpt-6-luna, gpt-6-sol, gpt-5.4-nano, gpt-5.4-mini,
gpt-5.6-luna, gpt-5.6-terra, gpt-5.6-sol, gpt-4.1, gpt-4.1-mini,
gpt-4.1-nano and gpt-transcribe. Models that existing configs can name
keep a price until they shut down. gpt-3.5-turbo-1106 now costs 1.00 and
2.00 dollars per million tokens, as the pricing page says.

Removed: gpt-4o snapshots that the pricing page does not list, and
models that are already shut down or are not chat models (gpt-4-32k,
gpt-4 preview snapshots, gpt-3.5-turbo-instruct). They now get the
fallback price and the activity log warning.

A model that is not in the table uses the prices of the default model of
a new config. The defaults are still gpt-4o-mini and whisper-1; the
warning text takes the model names from the table.
The model table now also holds the request options for each chat model.

reasoning_effort "none" goes to gpt-6-luna and gpt-6-sol. Their model
pages say that reasoning.effort supports none and defaults to medium.
Translation does not need reasoning, and reasoning tokens are billed as
output tokens and add delay. Other models get no reasoning_effort:
gpt-5.4-nano and gpt-5.4-mini already default to none, and GPT-4o,
GPT-4.1 and model names that are not in the table may reject it.

The translation instructions go in a developer message for the GPT-5
and GPT-6 models in the table, and in a system message for GPT-4o,
GPT-4.1, older models and names that are not in the table. The Chat
Completions reference says "With o1 models and newer, `developer`
messages replace the previous `system` messages." No page says in
plain words that a system message is still accepted and treated as a
developer message for these models, so the known reasoning models get
the developer role.
A new config now uses gpt-6-luna for translation and gpt-transcribe for
transcription. A config file that names a model keeps it: the serde
default only fills a missing field. A config without transcription_model
now gets gpt-transcribe instead of whisper-1. Model names that are not
in the table get the gpt-6-luna and gpt-transcribe prices.

The model lists in the settings come from the model table:
gpt-6-luna, gpt-6-sol, gpt-5.4-nano, gpt-5.4-mini, gpt-4.1-mini and
gpt-4o-mini for translation, and gpt-transcribe for transcription.
Models that are scheduled to shut down are not in the lists.

The transcription model is now a text field with a model list, the same
as the translation model, because the list has one model and the config
takes any name.

config.toml.example and README name the new defaults.
The model table now holds the shutdown date and the replacement model
from developers.openai.com/api/docs/deprecations (checked 2026-09-28):

- gpt-3.5-turbo, gpt-3.5-turbo-0125: 2026-10-23, gpt-5.6-terra
- gpt-3.5-turbo-1106: 2026-09-28, gpt-5.6-terra
- gpt-4, gpt-4-0613, gpt-4-turbo, gpt-4-turbo-2024-04-09,
  gpt-4o-2024-05-13: 2026-10-23, gpt-5.6-sol
- gpt-4.1-nano: 2026-10-23, gpt-5.6-luna
- whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe: 2027-02-26,
  gpt-transcribe

At start and when settings are saved, the activity log shows the date
and the replacement for the translation and the transcription model.
The warning states the date and does not compare it with the clock, so
the tests do not depend on the date and the warning also shows after
the shutdown, when requests fail.

The model table rows stay on one line each (rustfmt skip), so the table
reads as a table.
"gpt-6-luna " passed the check for an empty model name, and the API
then returned model_not_found. Save now removes spaces around the
translation and the transcription model name, and the settings show
the trimmed names. A blank transcription model name is now refused like
a blank translation model name.

The checks before the file write move into config_to_save, and the file
write and the config update into save_config, so a test can check the
saved config without writing config.toml in the working directory. The
test does not click the Save button for that reason.
The shutdown warnings recommend gpt-5.6-luna, gpt-5.6-terra and
gpt-5.6-sol. These models use reasoning effort medium by default, so a
user who follows a warning got reasoning tokens on each translation,
with more cost and delay. Each model page on developers.openai.com says
that the models support reasoning effort none (checked 2026-09-28).
The table now sends none for them, as for gpt-6-luna and gpt-6-sol.
d6e added 28 commits September 28, 2026 11:41
The theme test for the meter tracks looked for the level meter track
by its size, but it did not check that it found it. If the level meter
changed size or was not painted, the test did not check its colour and
still passed. The test now checks that it finds exactly one level meter
track.
The tests for a split recording checked only the duration text. The
duration bar gets the same minimum as the text, but no test checked it.
If the bar got the configured minimum after a split, all tests still
passed.

Two new tests measure the filled part of the duration bar for a 0.5 s
recording with a 1 s minimum. After a split the bar is full. Without a
split it is half full.
The hover text of the Min Duration setting said that the minimum applies
to every recording before transcription. Since 9b3aef6 the minimum
applies only to a whole recording. A recording that reaches the length
limit is split into parts, and the minimum does not apply to the parts
(a part without sound above the noise gate is still dropped, since
379c39c). The hover text now says so and reads the limit from
MAX_RECORDING.

The doc of InstructionsRole::System named only the GPT-4o and GPT-4.1
models. The GPT-4 Turbo, GPT-4 and GPT-3.5 Turbo rows also use the
system role. The doc now names them.
The hover text of Min Duration said that every part of a split
recording is transcribed, whatever its length. A part with no sample
above the noise gate threshold is not sent. The hover text now says
only that the minimum does not apply to the parts. The doc of
min_transcription_duration said that those parts are sent. It now says
that the minimum never stops them.

The doc of to_i16 said that the clamp stops values from wrapping
around. A cast from f32 to i16 saturates and never wraps. The clamp
keeps the range symmetric: without it, -2.0 gives i16::MIN. The doc
now says so, and the test name says what the test checks.

The doc of recording_duration said that it counts from the start of the
recording. After a split it counts from the split. The doc now says so.

The doc of encode_for_upload said that encoding on a blocking thread
keeps a runtime worker free. The processing loop runs in block_on on
the processing thread, so encoding in the loop would stop the loop and
its check for shutdown. The doc now gives that reason.
The comment in test_last_part_of_a_split_recording_shows_ready_below_the_minimum
said that the last part of a split recording is sent however short it is.
A last part without a sample above the noise gate threshold is not sent
(Recorder::process ends it with no samples, and InputHandler sends no
AudioData for it). The comment now says only that the minimum does not
apply to that part.
Config::load used every value in config.toml as written. A NaN
noise_gate_hold_time kept the gate open forever, a NaN
noise_gate_threshold never opened it, max_audio_files = 0 deleted every
saved recording, and a very large display_time stopped the chatbox.

The ranges of the settings window are now constants in config.rs, and
the window and the loader both use them. On load, a number outside its
range moves to the nearest end of the range. A number that is not
finite, a blank text and port 0 become the default. Text is trimmed.
If the two ports are the same, one goes back to its default. Each
replacement gives a warning with the key, the value found and the value
used; main logs the warnings in the activity log when the logger
exists. The API key is not shown in its warning. The file does not
change until the user saves the settings.

Load does not fail on a bad value: each one has a usable replacement,
and a failed load stops the program before the settings window, which
is where the user can correct the value.
Since 11712ac the loader clamps both values to the range of the settings
window, 1 to 100 recordings and 1 to 120 requests per minute. A config
with max_audio_files = 500 then deleted all but 100 saved recordings at
the next recording, and a config with more than 120 requests per minute
slowed an API account with a higher limit. The settings window clamps
the same way when it draws the field (egui DragValue clamps existing
values by default), so the lower maximum also applied on Save.

Both maximums are now 10000, in the loader and the settings window.
The settings window saved an OSC address that is blank after trimming.
The chatbox and the typing indicator then send to ":9000", which fails
for every message until the next start. At the next start the loader
replaces the blank address with the default (since 11712ac). Save now
refuses it, like a blank model name.
The recorder reserved a whole 30 s part at the start of every recording
and part, and passed that buffer on with the samples. A 1 s utterance at
48 kHz stereo carried about 11.5 MB of capacity through the audio event
channel and the processing queue, and a full queue could hold about
390 MB.

A recording or part now reserves 5 s. When speech is longer, the buffer
doubles (10, 20, then 30 s) and never grows past the 30 s split length,
so the callback copies the samples at most 3 times in a part. A quiet
part or a recording that ended without sound no longer keeps a buffer
that grew for the next part.

Recorder::for_stream computes both sizes from the channel count and
sample rate, and replaces max_recording_samples.
run_processing_loop waited for the audio thread with a blocking
std::sync::mpsc recv on the runtime thread. If the driver never
returned from opening the stream, the loop never reached its shutdown
arm, main never finished joining the processing thread, and the
application did not exit when the window closed.

processing_loop::wait_for_audio_start now awaits a tokio oneshot through
shutdown.run_until, with a timeout of 15 s (AUDIO_START_TIMEOUT). A
timeout gives the startup error "cannot start audio input: timed out
after 15 s" in the activity log. Shutdown ends the wait and the loop
returns without an error. An audio thread that ends without a result
gives "cannot start audio input: the audio thread stopped".

The audio thread is not joined. If the stream starts after the loop
stopped waiting, hold_audio_stream drops it at once: it keeps the stream
only while shutdown is not requested and the processing thread has not
ended (AppState::is_processing_stopped). This also stops capture if the
processing thread ends for another reason after the stream started.

Not tested: the wiring in main.rs, which needs an audio device.
PriceEstimator::add_cost saves total_cost.txt after every utterance. A
failed save went to stderr only, so the GUI showed a total that the next
start would not load, with no reason.

The failure now goes to the activity log as an error. A save that fails
keeps failing the same way until the user fixes the cause, so an error
is logged only when it differs from the last one. The next save that
works logs "Saved the total cost to ... again" and clears the last
error, so the same failure after that is logged again.

app_state::FailureLog holds that last error; the typing indicator uses
it next. PriceEstimator::with_cost_file takes the file path, so the test
writes in the temp directory and not in the working directory.
TypingIndicator sends over UDP at the start and end of every recording.
A failed send went to stderr only, so a wrong OSC address or port gave
no sign in the GUI.

The failure now goes to the activity log as an error, through the
FailureLog that the total cost save uses. While the cause stays, every
send fails with the same message, so the log gets:
- the first failure,
- a failure with a different message (the message names the address,
  so a new wrong address or port is logged),
- "Typing indicator sent to ... again" at the first send that works
  after failures. That clears the last failure, so the same failure
  after it is logged again.

The failure state is shared by the clones of the indicator.
TypingIndicator::new takes the logger.

On Linux, a closed VRChat does not make the send fail: UDP to a port
that nothing listens on succeeds on an unconnected socket. So this
reports address and network errors, not a missing receiver. This was
checked on Linux only; other systems can behave differently.
No change in behaviour. The playback stream is to report its errors the
same way as the input stream, so the reporter no longer fixes the
channel type and the message label: StreamErrorReporter<E> takes the
label and a function that makes the value to send. The input stream uses
audio_recording::input_error_reporter, which sends
AudioEvent::InputError("Audio input error: ...") as before.
The error callback of the test playback stream printed to stderr, so a
playback that failed after it started gave no sign in the GUI.

The callback runs on an audio thread, which must not call Logger (it
prints, and waking the GUI takes the egui context lock). It now reports
through StreamErrorReporter, as the input stream does: a try_send of
"Playback error: ..." that does not wait, and only when the error
differs from the last one, since cpal can call the error callback in a
loop with the same error. The processing loop receives the messages in
processing_loop::PlaybackErrors and logs them as errors, in a select arm
after the test recording limit. Each playback builds a new stream with
a new reporter, so the next playback logs its first error again.

AudioOutput::play takes the sender. Not tested: the wiring in
build_output_stream and in the main loop, which needs an output device.
process_audio added the transcription cost and the translation cost
together after the translation. A failed translation request or a
failed recording save returned before that, so the total cost did not
include a transcription that the API had already charged for. Now the
transcription cost is added as soon as the transcription is back, and
the translation cost after the translation.

Not tested: both requests go to https://api.openai.com, and the tests
have no local server that can answer them.
deliver_translation adds the translation cost to the total and sends
the text to the chatbox. It needs no API request, so tests can call it
with a response body. No change in behaviour.
When the model refuses, the Chat Completions message has "content": null
and a "refusal" string. parse_response read content as a string, so the
activity log showed "Error: invalid type: null, expected a string" and
the tokens of the response were not added to the total cost.

The API reference (developers.openai.com/api/reference/resources/chat)
gives content as string or null, refusal as optional string or null, and
finish_reason as stop, length, tool_calls, content_filter or
function_call. parse_response now returns the tokens with either the
text or a NoTranslation reason:

- refusal set: "The model refused to translate: <refusal>"
- content null or blank: "The model returned no translation
  (finish_reason: <reason>)", with its own wording for content_filter
- no choices: "The model returned no translation (the response has no
  choices)"
- finish_reason length with content: not sent. The request sets no
  token limit, so the text reached the limit of the model, which is far
  longer than a translation of a 30 s part. Such a text is not a
  translation, and its end is missing.

deliver_translation adds the cost first, then logs the reason as an
error, turns the typing indicator off and sends nothing to the chatbox.
Without usage, the output tokens are estimated from the refusal text.
Malformed JSON is still an error of process_audio.
The settings window got the replaced config as both the draft and the
saved config, so Save stayed disabled and the bad value stayed in
config.toml. main.rs now passes the load warnings to run_gui. When there
is a warning, the window shows unsaved changes and enables Save until
the next successful save. Reset keeps the replaced values, and it shows
only when the draft has changes. The file is still written only when the
user clicks Save.

The warning text now tells the user to click Save Settings.

New tests load a config file with osc.display_time = 999999 and check
that the window shows unsaved changes, that a click on Save reaches the
save handler, and that Reset keeps the replaced value and the unsaved
changes. The API key is blank, so the save handler refuses the config
and writes no config.toml. A file with no replaced value must still
have nothing to save. The first three tests were run red first, before
the fix.

Not tested: that a successful save clears the unsaved state, because a
test that saves a valid config writes config.toml in the working
directory.
transcribe_audio returned an error for a successful response with an
empty text, before process_audio added the transcription cost. The
estimate is per minute of audio, so that audio was left out of the
total cost.

transcribe_audio now returns the text of any successful response that
parses, through parse_transcription. The new accept_transcription adds
the cost first. Then, for an empty or blank text, it logs "The
transcription is empty, so nothing was translated" as an error, turns
the typing indicator off and returns no text, so process_audio sends
no translation request and saves no recording. Before, a text of only
spaces or line breaks went on to the translation request; it is now
handled as an empty text.

A failed request and a body that does not parse are still errors of
process_audio and are not costed, as for a translation.

New tests give a transcription response body to parse_transcription
and accept_transcription, so the cost and the activity log can be
checked without the network. An empty or blank text must add the cost
of one second of audio, log that nothing was translated, turn the
typing indicator off, and return no text to translate. A normal text is
costed and returned. A body with a null text is an error and is not
costed. The Chat Completions harness now uses the same fixture. The
tests were written first; they did not compile until parse_transcription
and accept_transcription were added.
The recorder tests did not fail when the buffer grew by more than the
documented doubling. With the growth changed from 2 to 4 times the
capacity, the 12 s part held 20 s, below the 24 s bound, and the count
of 7 growths still held. The full suite stayed green.

The stream harness now checks after each copy to a larger buffer that
the buffer holds less than twice its samples, and counts the copies in
each part. The 42 s test asserts at most 3 copies in each part, which
fails for a growth of 1.8 times (4 copies in the first part).

New tests end recordings just after 5 s and 10 s at 48 kHz stereo and
44.1 kHz with 3 channels. The buffer that reaches the processing side
must hold at most 10 s and 20 s, and less than twice its samples. They
fail for a growth of 4 times, for a growth to 30 s at the first copy,
and for a growth to twice the needed length.
Comments and doc comments that the Stage 9 review found inaccurate:

- config.rs: the load moves numbers into range, but a value that does
  not fit the type of its field (a negative count, port 70000, a decimal
  number in an integer field) still stops the load with a TOML error. A
  blank API key and -0.0 pass unchanged. A requests_per_minute of 0 lets
  one request through at the end of each minute, not none.
- recorder.rs: the buffer growth counts capacity increases, which can
  but need not copy the samples; the growing tests assert a bound, not
  an exact capacity.
- processing_loop.rs: the log line of an audio thread panic can come
  after the processing thread's line.
- price_estimator.rs: the cost is saved after each transcription and
  each translation.
- typing_indicator.rs: a recording turns the indicator on once and off
  up to twice.
- gui.rs: file_differs stays set until the next successful save.
- audio_processing.rs: accept_transcription logs a fixed message, not
  the text.
- tests.rs: the transcription test with "text": null is renamed to say
  so, and the Delivery doc is reworded.

Warning texts: an api_key of only spaces now says BabbleBoop uses "a
blank text" instead of "the text without them". The port clash reason
says "the osc.output_port in use" (or input), because the clash can be
with a port that the loader just replaced, for example output_port = 0.

The new api_key test was run red first against the old text: left
["the text without them"], right ["a blank text"].
A Chat Completions choice with content and finish_reason content_filter
went to the chatbox. The filter removed part of the text, so what is
left is not the whole translation. It now becomes
NoTranslation::Filtered and is handled like a cut off text: an error in
the activity log, nothing to the chatbox, the typing indicator off, and
the tokens added to the total cost.

The message for a null content with content_filter now uses the same
wording: "The content filter removed the translation, so nothing was
sent (finish_reason: content_filter)".

A refusal still comes before the content filter, so a refusal with
content_filter logs the refusal text. Tests: filtered content, null
content with content_filter, and a refusal with content_filter, all
through the deliver_response harness.
send_to_chatbox paused display_time after every chunk, also after the
last one. The processing loop awaits process_audio, so the next
utterance could not be transcribed or translated until that pause
ended.

The new Chatbox, owned by the processing loop, keeps the time the last
chunk went out. Before each chunk it waits until the chunk before it,
of this message or the one before, was on screen for display_time, and
then sends at once. The pause between the chunks of one message stays
the same. After the last chunk there is no pause, and the first chunk
of the next message waits only for what is left of display_time, from
the display_time in the config at that time. A message that is not
sent (too short, empty transcription, no translation) does not wait.
The wait is a tokio sleep inside process_audio, which the loop runs
through shutdown.run_until, so shutdown still stops it.

process_audio and deliver_translation take the Chatbox in place of the
socket. The typing indicator now turns off right after the last chunk,
not after the pause.

Tests (paused clock, local UDP socket): a 3 chunk message goes out at
0, 10 and 20 s and send returns at 20 s; the next message waits the
rest of the display time, counted from the last chunk; no wait after
the display time; a changed display_time applies; a response without
a translation does not wait; shutdown stops the wait before the next
message.
audio.silence_threshold counted the audio buffers with the noise gate
closed. The device sets the buffer length, so the same value gave a
different silence on each device. audio.silence_duration (f32, seconds,
default 1.0, range 0.1 to 10 s) replaces it.

The recorder counts the frames of the quiet buffers since the gate closed
and ends the recording with the buffer during which they reach the
duration, rounded to the nearest frame. The hold time is unchanged: the
count starts when the gate closes. The value is read for each buffer from
AudioParams (f32 bits), so a saved change applies to the silence in
progress. A duration that is not a number ends the recording at the first
quiet buffer instead of never.

The GUI shows the silence in seconds ("0.5s / 2.0s") from the new
quiet_time atomic, which replaces silent_frames. The field is "Silence
Duration (s)".

The loader clamps the new value like the other numbers (not finite goes
to the default). A file that still has audio.silence_threshold loads; the
key is ignored with one warning that names audio.silence_duration and the
value in use. The loader tests now change values in config.toml.example
instead of the v0.5.0 file, which gets that warning.
config.toml, total_cost.txt and recordings/ were relative paths, so they
resolved against the working directory. The README says to put the
config next to the executable. A shortcut or launcher with another start
folder created a new blank config and asked for the API key again.

data_dir::locate selects one data folder at startup: the folder of the
executable, unless config.toml is in the working directory and not next
to the executable. Then it keeps the working directory, which earlier
versions always used, so existing setups and cargo run from the repo
folder keep their settings and total cost. If both folders have a
config, the one next to the executable is used. If the folder of the
executable is not known, the working directory is used and the activity
log shows an error. A working directory that is the folder of the
executable in another spelling counts as that folder; the paths are
canonicalized only to compare them, so the log does not show a \\?\
path on Windows.

The decision is data_dir::choose, a function of the working directory,
the folder of the executable and a config file check, so tests do not
touch real paths. DataDir names the three files. main loads the config
from it, logs the folder in the activity log, and passes it to the
processing loop (ProcessingServices::new: cost file and recordings
folder, also after saved settings) and the GUI (Save writes the file
that was loaded). CONFIG_PATH, TOTAL_COST_FILE and RECORDINGS_DIR are
removed. The config load error names the path.

Tests: each case of the decision (config only in the working directory,
only next to the executable, both, neither, same folder, folder of the
executable unknown), the file check and the other spelling on temp
folders, cost load and save and recordings in the data folder, and GUI
Save to the loaded file in a temp folder. Tests that only compare prices
use a cost file in a temp folder that does not exist, so no test reads
or writes total_cost.txt in the working directory. README describes the
folders; .gitignore ignores recordings/.
Since c2cff50 the next message waits in Chatbox::send for the rest of
the display time of the previous one. The processing loop checks the
translation toggle only when it takes an audio event, so a message that
was already translated went to the chatbox even if translation was
switched off during that wait.

deliver_translation now does the display wait first, with the new
Chatbox::wait_for_display, and then reads app_state.enabled. If
translation is off, it sends nothing, turns the typing indicator off and
logs an Info line. The translation cost is still added. The check also
covers a toggle during the rate limiter wait or the API calls, which
the processing loop did not stop before c2cff50 either. A message that
has started to go out is still sent to its last chunk, as before.
6fe6540 used the config next to the executable when both folders had
one. A user who starts BabbleBoop from a shortcut whose start folder
holds the config, and once double clicks the executable (the working
directory is then the folder of the executable), gets a new default
config next to the executable. From then on that config was used, and
the real settings and total cost were ignored. Also, with no config
anywhere and a folder of the executable that cannot be written (Program
Files with another start folder, /usr/local/bin run from $HOME), the
first start failed with the load error dialog; before 6fe6540 the config
was created in the working directory.

data_dir::choose now uses, in this order: the working directory if it
has config.toml; the folder of the executable if it has config.toml; if
no folder has one, the folder of the executable if BabbleBoop can write
to it, else the working directory. The folder of the executable unknown
still gives the working directory. If both folders have a config, the
activity log (Info) names the config.toml and total_cost.txt next to the
executable that are not used. If the folder of the executable cannot be
written, the log names it and the error.

choose stays pure: write access comes in as a function, like the config
check, and is called only when no folder has a config and the working
directory is not the folder of the executable. locate_in checks it with
data_dir::can_write, which creates an empty file with a unique name
(process id and time) with create_new and removes it.

Tests: each case of the decision (config only in the working directory,
only next to the executable, both, neither with a writable or an
unwritable folder of the executable, same folder, folder unknown), that
write access is checked only when it decides, the log line of the both
case names the ignored paths, and on temp folders that the check leaves
no file, fails for a missing folder and (unix only) for a read only
folder. README describes the order and that cargo run in a fresh clone
keeps the config in target/<profile>, which cargo clean deletes.
The config key for the silence setting changed (silence_threshold is now
silence_duration in seconds), and new installs default to gpt-6-luna and
gpt-transcribe.
@d6e
d6e merged commit 131c315 into main Sep 29, 2026
2 checks passed
@d6e
d6e deleted the review-fixes branch September 29, 2026 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant