Repository navigation
Fix review findings, update OpenAI models, release 0.6.0 - #11
Merged
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
gpt-6-lunafor translation andgpt-transcribefor transcription. The model fields accept any name. The presets dropgpt-4,gpt-4-turboandgpt-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-1and thegpt-4otranscription models shut down on 2027-02-26.audio.silence_threshold(a count of audio buffers, so its length depended on the device) is nowaudio.silence_durationin seconds, default 1.0. An old config still loads; the log says the old key is not used, and Save writes the new key.config.tomlin 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.txtandrecordings/follow the config. The log shows the folder at startup.max_audio_files = 0) are replaced when the file loads, and each replacement is logged. The file changes only when you click Save.display_time, so the next utterance is processed without a fixed 3 s delay.Other fixes
string_sliceis now denied), unwraps on file metadata, and panickingDurationconversions (banned inclippy.toml).--all-targets.Not verified
gpt-6-luna(adevelopermessage andreasoning_effort: "none") is checked only against the API reference.Tests: 207 unit tests, up from 9. Every commit passes
cargo fmt --check,cargo clippy --all-targets -- -D warningsandcargo test.