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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and Termleaf uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.2] - 2026-08-01

### Fixed

- Replaced the synthetic typewriter effects with compact, edited CC0 field
recordings of a Hermes Precisa 305 for more natural playback, while removing
the low-frequency resonance that sounded like a drum on MacBook speakers.
- Added four real key-strike variations per sound profile and avoided immediate
repeats so sustained typing no longer sounds like one identical sample loop.
- Added restrained per-strike pitch, level, brightness, and decay variation to
make the recorded key differences perceptible without sounding randomized.

## [0.3.1] - 2026-08-01

### Changed
Expand Down Expand Up @@ -87,7 +99,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.1...HEAD
[Unreleased]: https://github.com/andy5090/termleaf/compare/v0.3.2...HEAD
[0.3.2]: https://github.com/andy5090/termleaf/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/andy5090/termleaf/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/andy5090/termleaf/compare/v0.2.4...v0.3.0
[0.2.4]: https://github.com/andy5090/termleaf/compare/v0.2.3...v0.2.4
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "termleaf"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
description = "A distraction-free terminal text editor built for focused writing."
repository = "https://github.com/andy5090/termleaf"
Expand Down
20 changes: 20 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,23 @@ license text is in [`assets/OFL-1.1.txt`](assets/OFL-1.1.txt).

- Project: <https://quiple.dev/font/galmuri>
- Source: <https://github.com/quiple/galmuri/blob/v2.40.4/dist/Galmuri9.bdf>

## Hermes Precisa 305 typewriter recordings

Termleaf's key, delete, and carriage-return effects are edited from recordings
of a Hermes Precisa 305 made by Joseph SARDIN. The optimized 44.1 kHz, 16-bit
mono PCM files embedded in Termleaf are derived from these 48 kHz, 24-bit
masters:

- Slow typewriter sequence, sound 2841: <https://bigsoundbank.com/machine-a-ecrire-8-s2841.html>
- Typewriter space, sound 2843: <https://bigsoundbank.com/machine-a-ecrire-espace-s2843.html>
- Typewriter bell, sound 2844: <https://bigsoundbank.com/typewriter-bell-s2844.html>

The recordings are dedicated to the public domain under the Creative Commons
CC0 1.0 Universal license. Attribution is not required, but is included here in
appreciation of the author's work.

- Author: Joseph SARDIN
- Library: <https://bigsoundbank.com>
- License: <https://creativecommons.org/publicdomain/zero/1.0/>
- Library license terms: <https://bigsoundbank.com/licenses.html>
Binary file modified assets/typewriter-backspace.wav
Binary file not shown.
Binary file added assets/typewriter-key-2.wav
Binary file not shown.
Binary file added assets/typewriter-key-3.wav
Binary file not shown.
Binary file added assets/typewriter-key-4.wav
Binary file not shown.
Binary file added assets/typewriter-key-deep-2.wav
Binary file not shown.
Binary file added assets/typewriter-key-deep-3.wav
Binary file not shown.
Binary file added assets/typewriter-key-deep-4.wav
Binary file not shown.
Binary file modified assets/typewriter-key-deep.wav
Binary file not shown.
Binary file added assets/typewriter-key-soft-2.wav
Binary file not shown.
Binary file added assets/typewriter-key-soft-3.wav
Binary file not shown.
Binary file added assets/typewriter-key-soft-4.wav
Binary file not shown.
Binary file modified assets/typewriter-key-soft.wav
Binary file not shown.
Binary file modified assets/typewriter-key.wav
Binary file not shown.
Binary file modified assets/typewriter-return.wav
Binary file not shown.
178 changes: 146 additions & 32 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,25 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

const TYPEWRITER_WAV: &[u8] = include_bytes!("../assets/typewriter-key.wav");
const TYPEWRITER_DEEP_WAV: &[u8] = include_bytes!("../assets/typewriter-key-deep.wav");
const TYPEWRITER_SOFT_WAV: &[u8] = include_bytes!("../assets/typewriter-key-soft.wav");
const KEY_VARIANT_COUNT: usize = 4;
const TYPEWRITER_WAVS: [&[u8]; KEY_VARIANT_COUNT] = [
include_bytes!("../assets/typewriter-key.wav"),
include_bytes!("../assets/typewriter-key-2.wav"),
include_bytes!("../assets/typewriter-key-3.wav"),
include_bytes!("../assets/typewriter-key-4.wav"),
];
const TYPEWRITER_DEEP_WAVS: [&[u8]; KEY_VARIANT_COUNT] = [
include_bytes!("../assets/typewriter-key-deep.wav"),
include_bytes!("../assets/typewriter-key-deep-2.wav"),
include_bytes!("../assets/typewriter-key-deep-3.wav"),
include_bytes!("../assets/typewriter-key-deep-4.wav"),
];
const TYPEWRITER_SOFT_WAVS: [&[u8]; KEY_VARIANT_COUNT] = [
include_bytes!("../assets/typewriter-key-soft.wav"),
include_bytes!("../assets/typewriter-key-soft-2.wav"),
include_bytes!("../assets/typewriter-key-soft-3.wav"),
include_bytes!("../assets/typewriter-key-soft-4.wav"),
];
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);
Expand All @@ -34,11 +50,13 @@ struct Clip {
pub struct SoundPlayer {
stream: RefCell<Option<MixerDeviceSink>>,
stream_healthy: Arc<AtomicBool>,
classic_key: Clip,
deep_key: Clip,
soft_key: Clip,
classic_keys: [Clip; KEY_VARIANT_COUNT],
deep_keys: [Clip; KEY_VARIANT_COUNT],
soft_keys: [Clip; KEY_VARIANT_COUNT],
backspace: Clip,
carriage_return: Clip,
key_variant_state: Cell<u32>,
last_key_variant: Cell<Option<usize>>,
last_backspace: Cell<Option<Instant>>,
last_stream_retry: Cell<Option<Instant>>,
}
Expand All @@ -51,24 +69,30 @@ impl SoundPlayer {
Self {
stream: RefCell::new(stream),
stream_healthy,
classic_key: Clip::decode(TYPEWRITER_WAV),
deep_key: Clip::decode(TYPEWRITER_DEEP_WAV),
soft_key: Clip::decode(TYPEWRITER_SOFT_WAV),
classic_keys: TYPEWRITER_WAVS.map(Clip::decode),
deep_keys: TYPEWRITER_DEEP_WAVS.map(Clip::decode),
soft_keys: TYPEWRITER_SOFT_WAVS.map(Clip::decode),
backspace: Clip::decode(BACKSPACE_WAV),
carriage_return: Clip::decode(RETURN_WAV),
key_variant_state: Cell::new(0x7A_DA_C0_DE),
last_key_variant: Cell::new(None),
last_backspace: Cell::new(None),
last_stream_retry: Cell::new(None),
}
}

/// Mix one printing-key strike immediately.
pub fn play_key(&self, profile: &str) {
let clip = match profile {
"deep" => &self.deep_key,
"soft" => &self.soft_key,
_ => &self.classic_key,
let clips = match profile {
"deep" => &self.deep_keys,
"soft" => &self.soft_keys,
_ => &self.classic_keys,
};
self.play(clip);
let (state, variant) =
next_key_variant(self.key_variant_state.get(), self.last_key_variant.get());
self.key_variant_state.set(state);
self.last_key_variant.set(Some(variant));
self.play(&clips[variant]);
}

/// Mix the separate, gentle delete-key release effect.
Expand Down Expand Up @@ -169,6 +193,15 @@ fn backspace_playback_allowed(last: Option<Instant>, now: Instant) -> bool {
last.is_none_or(|last| now.duration_since(last) >= BACKSPACE_MIN_INTERVAL)
}

fn next_key_variant(state: u32, last: Option<usize>) -> (u32, usize) {
let state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let mut variant = ((state >> 16) as usize) % KEY_VARIANT_COUNT;
if last == Some(variant) {
variant = (variant + 1 + (state as usize & 1)) % KEY_VARIANT_COUNT;
}
(state, variant)
}

fn stream_retry_allowed(last: Option<Instant>, now: Instant) -> bool {
last.is_none_or(|last| now.duration_since(last) >= STREAM_RETRY_INTERVAL)
}
Expand Down Expand Up @@ -224,35 +257,100 @@ fn decode_pcm_wave(wav: &[u8]) -> Option<(u16, u32, Vec<f32>)> {
mod tests {
use super::*;

fn embedded_wavs() -> Vec<&'static [u8]> {
TYPEWRITER_WAVS
.into_iter()
.chain(TYPEWRITER_DEEP_WAVS)
.chain(TYPEWRITER_SOFT_WAVS)
.chain([BACKSPACE_WAV, RETURN_WAV])
.collect()
}

fn rms(samples: &[f32]) -> f32 {
(samples.iter().map(|sample| sample * sample).sum::<f32>() / samples.len() as f32).sqrt()
}

fn tone_power(samples: &[f32], sample_rate: u32, frequency: f32) -> f64 {
let (real, imaginary) =
samples
.iter()
.enumerate()
.fold((0.0, 0.0), |(real, imaginary), (index, sample)| {
let phase = std::f64::consts::TAU * frequency as f64 * index as f64
/ sample_rate as f64;
(
real + *sample as f64 * phase.cos(),
imaginary + *sample as f64 * phase.sin(),
)
});
(real * real + imaginary * imaginary) / (samples.len() * samples.len()) as f64
}

fn band_power(samples: &[f32], sample_rate: u32, frequencies: &[f32]) -> f64 {
frequencies
.iter()
.map(|frequency| tone_power(samples, sample_rate, *frequency))
.sum()
}

#[test]
fn embedded_typewriter_sound_is_valid_pcm() {
for wav in [
TYPEWRITER_WAV,
TYPEWRITER_DEEP_WAV,
TYPEWRITER_SOFT_WAV,
BACKSPACE_WAV,
RETURN_WAV,
] {
for wav in embedded_wavs() {
let (channels, sample_rate, samples) =
decode_pcm_wave(wav).expect("embedded WAV should decode");
assert_eq!(channels, 1);
assert_eq!(sample_rate, 44_100);
assert!(samples.len() > 3_000);
assert!(samples.iter().any(|sample| sample.abs() > 0.25));
assert!(samples.len() > 2_500);
assert!(samples.iter().any(|sample| sample.abs() > 0.2));
}
}

#[test]
fn key_and_backspace_use_distinct_recordings() {
let (_, _, key) = decode_pcm_wave(TYPEWRITER_WAV).expect("key WAV should decode");
fn classic_keys_keep_a_short_recorded_decay() {
for wav in TYPEWRITER_WAVS {
let (_, sample_rate, samples) = decode_pcm_wave(wav).expect("key WAV should decode");
let duration = samples.len() as f32 / sample_rate as f32;
let section = |start: f32, end: f32| {
&samples[(start * sample_rate as f32) as usize..(end * sample_rate as f32) as usize]
};

assert!((0.2..=0.26).contains(&duration));
assert!(
rms(section(0.0, 0.08)) > rms(section(duration - 0.05, duration)) * 2.0,
"the recorded strike should decay instead of ending abruptly"
);
}
}

#[test]
fn built_in_effects_do_not_emphasize_drum_like_resonances() {
let rumble_frequencies = [
68.0, 82.0, 112.0, 126.0, 164.0, 170.0, 238.0, 252.0, 290.0, 328.0,
];
let mechanical_frequencies = [
360.0, 420.0, 480.0, 580.0, 620.0, 720.0, 850.0, 980.0, 1_150.0, 1_400.0, 1_650.0,
1_800.0, 2_100.0, 2_400.0, 2_900.0, 3_400.0,
];

for wav in embedded_wavs() {
let (_, sample_rate, samples) =
decode_pcm_wave(wav).expect("embedded WAV should decode");
let rumble = band_power(&samples, sample_rate, &rumble_frequencies);
let mechanical = band_power(&samples, sample_rate, &mechanical_frequencies);
assert!(
rumble < mechanical * 0.2,
"typewriter effects should favor mechanical detail over low-frequency rumble"
);
}
}

#[test]
fn key_variants_and_backspace_use_distinct_recordings() {
let (_, _, key) = decode_pcm_wave(TYPEWRITER_WAVS[0]).expect("key WAV should decode");
let (_, _, deep) =
decode_pcm_wave(TYPEWRITER_DEEP_WAV).expect("deep key WAV should decode");
decode_pcm_wave(TYPEWRITER_DEEP_WAVS[0]).expect("deep key WAV should decode");
let (_, _, soft) =
decode_pcm_wave(TYPEWRITER_SOFT_WAV).expect("soft key WAV should decode");
decode_pcm_wave(TYPEWRITER_SOFT_WAVS[0]).expect("soft key WAV should decode");
let (_, _, backspace) =
decode_pcm_wave(BACKSPACE_WAV).expect("backspace WAV should decode");
let (_, _, carriage_return) =
Expand All @@ -265,6 +363,22 @@ mod tests {
&key[..key.len().min(1_000)],
&backspace[..key.len().min(1_000)]
);
for pair in TYPEWRITER_WAVS.windows(2) {
assert_ne!(pair[0], pair[1], "adjacent key variants must differ");
}
}

#[test]
fn key_variant_selection_avoids_immediate_repetition() {
let mut state = 0x7A_DA_C0_DE;
let mut last = None;
for _ in 0..64 {
let (next_state, variant) = next_key_variant(state, last);
assert!(variant < KEY_VARIANT_COUNT);
assert_ne!(Some(variant), last);
state = next_state;
last = Some(variant);
}
}

#[test]
Expand All @@ -275,11 +389,11 @@ mod tests {
&samples[(start * sample_rate as f32) as usize..(end * sample_rate as f32) as usize]
};

assert!((0.42..=0.55).contains(&(samples.len() as f32 / sample_rate as f32)));
let lever = rms(section(0.0, 0.04));
let quiet_travel = rms(section(0.08, 0.18));
assert!((0.75..=0.85).contains(&(samples.len() as f32 / sample_rate as f32)));
let lever = rms(section(0.0, 0.08));
let quiet_travel = rms(section(0.08, 0.13));
let stop_and_bell = rms(section(0.21, 0.31));
assert!(lever > 0.025, "lever contact should lead");
assert!(lever > 0.02, "lever contact should lead");
assert!(
quiet_travel < stop_and_bell * 0.35,
"travel should leave space before the final strike"
Expand All @@ -289,7 +403,7 @@ mod tests {
"stop and high bell should be the main event"
);
assert!(
rms(section(0.32, 0.45)) > 0.01,
rms(section(0.55, 0.75)) > 0.02,
"bell should decay naturally"
);
}
Expand Down
Loading
Loading