Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and Termleaf uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.5] - 2026-08-02

### Fixed

- Restored typewriter audio on i686 Linux by backporting CPAL's 32-bit-safe
ALSA timestamp conversion, letting ALSA choose a device-compatible buffer
size, and rebuilding streams after backend failures.

## [0.3.4] - 2026-08-02

### Changed
Expand Down Expand Up @@ -116,7 +124,8 @@ and Termleaf uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Made forward Delete work for characters and line boundaries.
- Made Save As reliable through `F12`, including a Markdown default extension.

[Unreleased]: https://github.com/andy5090/termleaf/compare/v0.3.4...HEAD
[Unreleased]: https://github.com/andy5090/termleaf/compare/v0.3.5...HEAD
[0.3.5]: https://github.com/andy5090/termleaf/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/andy5090/termleaf/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/andy5090/termleaf/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/andy5090/termleaf/compare/v0.3.1...v0.3.2
Expand Down
4 changes: 1 addition & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "termleaf"
version = "0.3.4"
version = "0.3.5"
edition = "2021"
description = "A distraction-free terminal text editor built for focused writing."
repository = "https://github.com/andy5090/termleaf"
Expand All @@ -22,6 +22,12 @@ path = "src/bin/termleaf-update.rs"
crossterm = "0.28"
rodio = { version = "0.22.2", default-features = false, features = ["playback"] }

[patch.crates-io]
# CPAL 0.17.3 overflows when converting ALSA timestamps on 32-bit Linux,
# terminating the audio callback thread on its first buffer. The vendored copy
# backports upstream RustAudio/cpal#1137 until Rodio accepts CPAL 0.18.
cpal = { path = "vendor/cpal" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ship the patched CPAL with Cargo installations

When 0.3.5 is installed from crates.io using the README-supported cargo install termleaf --locked, this repository-local patch does not ship: Cargo excludes the nested vendor/cpal package from Termleaf's .crate archive and resolves CPAL from crates.io during packaging. Those i686 installations therefore receive the original CPAL 0.17.3 timestamp overflow and remain silent, so the patched dependency must be distributed in a form available to packaged consumers.

Useful? React with 👍 / 👎.


[profile.release]
opt-level = 3
lto = true
Expand Down
31 changes: 16 additions & 15 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ const BACKSPACE_WAV: &[u8] = include_bytes!("../assets/typewriter-backspace.wav"
const RETURN_WAV: &[u8] = include_bytes!("../assets/typewriter-return.wav");
const BACKSPACE_MIN_INTERVAL: Duration = Duration::from_millis(55);
const STREAM_RETRY_INTERVAL: Duration = Duration::from_secs(2);
#[cfg(all(target_os = "linux", target_arch = "x86"))]
const I686_STABILITY_BUFFER_FRAMES: u32 = 4_096;

struct Clip {
samples: Vec<f32>,
Expand Down Expand Up @@ -159,13 +157,6 @@ fn open_stream(stream_healthy: Arc<AtomicBool>) -> Option<MixerDeviceSink> {
let callback_state = Arc::clone(&stream_healthy);
let builder = DeviceSinkBuilder::from_default_device().ok()?;

// Older 32-bit x86 machines have less scheduling headroom. Rodio documents
// 2048-4096 frames as its stability-focused range, so use the upper bound
// there while retaining the lower-latency default on other targets.
#[cfg(all(target_os = "linux", target_arch = "x86"))]
let builder =
builder.with_buffer_size(rodio::cpal::BufferSize::Fixed(I686_STABILITY_BUFFER_FRAMES));

builder
.with_error_callback(move |error| {
if stream_error_requires_rebuild(&error) {
Expand All @@ -183,10 +174,7 @@ fn open_stream(stream_healthy: Arc<AtomicBool>) -> Option<MixerDeviceSink> {
}

fn stream_error_requires_rebuild(error: &StreamError) -> bool {
matches!(
error,
StreamError::DeviceNotAvailable | StreamError::StreamInvalidated
)
!matches!(error, StreamError::BufferUnderrun)
}

fn backspace_playback_allowed(last: Option<Instant>, now: Instant) -> bool {
Expand Down Expand Up @@ -256,6 +244,7 @@ fn decode_pcm_wave(wav: &[u8]) -> Option<(u16, u32, Vec<f32>)> {
#[cfg(test)]
mod tests {
use super::*;
use std::thread;

fn embedded_wavs() -> Vec<&'static [u8]> {
TYPEWRITER_WAVS
Expand Down Expand Up @@ -428,15 +417,15 @@ mod tests {
}

#[test]
fn only_device_loss_and_invalidated_streams_require_a_rebuild() {
fn backend_failures_require_a_rebuild_but_transient_underruns_do_not() {
assert!(!stream_error_requires_rebuild(&StreamError::BufferUnderrun));
assert!(stream_error_requires_rebuild(
&StreamError::DeviceNotAvailable
));
assert!(stream_error_requires_rebuild(
&StreamError::StreamInvalidated
));
assert!(!stream_error_requires_rebuild(
assert!(stream_error_requires_rebuild(
&StreamError::BackendSpecific {
err: rodio::cpal::BackendSpecificError {
description: "`alsa::poll()` returned POLLERR".into(),
Expand All @@ -458,4 +447,16 @@ mod tests {
start + STREAM_RETRY_INTERVAL
));
}

/// Exercise the real output callback when diagnosing a supported machine.
/// This stays ignored because CI and cross-build hosts may have no speaker.
#[test]
#[ignore = "requires a working default audio output device"]
fn hardware_audio_callback_stays_alive() {
let player = SoundPlayer::new();
assert!(player.stream.borrow().is_some(), "audio stream should open");
player.play_key("classic");
thread::sleep(Duration::from_millis(500));
assert!(player.stream_healthy.load(Ordering::Acquire));
}
}
Loading
Loading