diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb2e86..7e07ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index edc9ab9..43f4774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,7 +729,7 @@ dependencies = [ [[package]] name = "termleaf" -version = "0.3.1" +version = "0.3.2" dependencies = [ "crossterm", "rodio", diff --git a/Cargo.toml b/Cargo.toml index 39826ce..4c9a5fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index fad9576..a5e8465 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -12,3 +12,23 @@ license text is in [`assets/OFL-1.1.txt`](assets/OFL-1.1.txt). - Project: - Source: + +## 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: +- Typewriter space, sound 2843: +- Typewriter bell, sound 2844: + +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: +- License: +- Library license terms: diff --git a/assets/typewriter-backspace.wav b/assets/typewriter-backspace.wav index 7af7886..1724593 100644 Binary files a/assets/typewriter-backspace.wav and b/assets/typewriter-backspace.wav differ diff --git a/assets/typewriter-key-2.wav b/assets/typewriter-key-2.wav new file mode 100644 index 0000000..a1b514d Binary files /dev/null and b/assets/typewriter-key-2.wav differ diff --git a/assets/typewriter-key-3.wav b/assets/typewriter-key-3.wav new file mode 100644 index 0000000..f7793e2 Binary files /dev/null and b/assets/typewriter-key-3.wav differ diff --git a/assets/typewriter-key-4.wav b/assets/typewriter-key-4.wav new file mode 100644 index 0000000..7b48407 Binary files /dev/null and b/assets/typewriter-key-4.wav differ diff --git a/assets/typewriter-key-deep-2.wav b/assets/typewriter-key-deep-2.wav new file mode 100644 index 0000000..9b926ca Binary files /dev/null and b/assets/typewriter-key-deep-2.wav differ diff --git a/assets/typewriter-key-deep-3.wav b/assets/typewriter-key-deep-3.wav new file mode 100644 index 0000000..37264ef Binary files /dev/null and b/assets/typewriter-key-deep-3.wav differ diff --git a/assets/typewriter-key-deep-4.wav b/assets/typewriter-key-deep-4.wav new file mode 100644 index 0000000..af8f902 Binary files /dev/null and b/assets/typewriter-key-deep-4.wav differ diff --git a/assets/typewriter-key-deep.wav b/assets/typewriter-key-deep.wav index a247e08..05152ce 100644 Binary files a/assets/typewriter-key-deep.wav and b/assets/typewriter-key-deep.wav differ diff --git a/assets/typewriter-key-soft-2.wav b/assets/typewriter-key-soft-2.wav new file mode 100644 index 0000000..79dd266 Binary files /dev/null and b/assets/typewriter-key-soft-2.wav differ diff --git a/assets/typewriter-key-soft-3.wav b/assets/typewriter-key-soft-3.wav new file mode 100644 index 0000000..5f6f12d Binary files /dev/null and b/assets/typewriter-key-soft-3.wav differ diff --git a/assets/typewriter-key-soft-4.wav b/assets/typewriter-key-soft-4.wav new file mode 100644 index 0000000..a4bcc54 Binary files /dev/null and b/assets/typewriter-key-soft-4.wav differ diff --git a/assets/typewriter-key-soft.wav b/assets/typewriter-key-soft.wav index fc36f0c..80cfb21 100644 Binary files a/assets/typewriter-key-soft.wav and b/assets/typewriter-key-soft.wav differ diff --git a/assets/typewriter-key.wav b/assets/typewriter-key.wav index 7cbf817..d1fd196 100644 Binary files a/assets/typewriter-key.wav and b/assets/typewriter-key.wav differ diff --git a/assets/typewriter-return.wav b/assets/typewriter-return.wav index e0992c7..d45e2b2 100644 Binary files a/assets/typewriter-return.wav and b/assets/typewriter-return.wav differ diff --git a/src/audio.rs b/src/audio.rs index c531c2e..64898ef 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -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); @@ -34,11 +50,13 @@ struct Clip { pub struct SoundPlayer { stream: RefCell>, stream_healthy: Arc, - 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, + last_key_variant: Cell>, last_backspace: Cell>, last_stream_retry: Cell>, } @@ -51,11 +69,13 @@ 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), } @@ -63,12 +83,16 @@ impl SoundPlayer { /// 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. @@ -169,6 +193,15 @@ fn backspace_playback_allowed(last: Option, now: Instant) -> bool { last.is_none_or(|last| now.duration_since(last) >= BACKSPACE_MIN_INTERVAL) } +fn next_key_variant(state: u32, last: Option) -> (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, now: Instant) -> bool { last.is_none_or(|last| now.duration_since(last) >= STREAM_RETRY_INTERVAL) } @@ -224,35 +257,100 @@ fn decode_pcm_wave(wav: &[u8]) -> Option<(u16, u32, Vec)> { 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::() / 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) = @@ -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] @@ -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" @@ -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" ); } diff --git a/tools/build_typewriter_sound.py b/tools/build_typewriter_sound.py index f281656..f562024 100644 --- a/tools/build_typewriter_sound.py +++ b/tools/build_typewriter_sound.py @@ -1,49 +1,236 @@ #!/usr/bin/env python3 -"""Generate Termleaf's optional key, delete, and carriage-return sounds.""" +"""Build Termleaf's optimized sounds from CC0 typewriter recordings.""" from __future__ import annotations +import hashlib import math import pathlib -import random import struct +import tempfile +import urllib.request import wave SAMPLE_RATE = 44_100 ASSETS = pathlib.Path(__file__).resolve().parents[1] / "assets" - - -def burst( - t: float, - start: float, - decay: float, - noise: float, - tone: float, - frequencies: tuple[float, float], -) -> float: - if t < start: - return 0.0 - age = t - start - envelope = math.exp(-age / decay) - metallic = math.sin(math.tau * frequencies[0] * age) + 0.45 * math.sin( - math.tau * frequencies[1] * age - ) - return envelope * (noise + tone * metallic) - - -def write_wave(name: str, samples: list[float], peak_level: float = 0.88) -> None: - # Normalize both effects to the same conservative peak so overlapping - # keystrokes remain punchy without clipping Rodio's mixer too easily. +USER_AGENT = "termleaf-audio-builder/1.0" + +# Hermes Precisa 305 recordings by Joseph SARDIN, dedicated to the public +# domain under CC0 1.0. The hashes pin the exact masters used for the assets. +SOURCES = { + "typing": ( + "https://bigsoundbank.com/UPLOAD/bwf-en/2841.wav", + "ebde693739503bc1b213c97faa746bffeaf9f1a1239c047bc035d3469eed67e3", + ), + "space": ( + "https://bigsoundbank.com/UPLOAD/bwf-en/2843.wav", + "f681dda9b53feaf75540ab3310398b37855173d694b55cb183c846e076fafe9e", + ), + "bell": ( + "https://bigsoundbank.com/UPLOAD/bwf-en/2844.wav", + "deb29f0d2729450e0b2b0f7d3f3259811579c30e688822acec06e519372b0661", + ), +} + +# Per-strike adjustments stay below the threshold where they read as an +# obvious effect: speed changes pitch by roughly one percent, while the filter, +# level, and tail offsets reinforce the differences already in the recordings. +VARIANT_TUNING = [ + (0.990, 0.94, 0.92, 0.97, 0.008), + (0.997, 1.00, 1.00, 1.00, 0.000), + (1.008, 1.04, 1.07, 0.95, -0.006), + (1.015, 1.08, 1.12, 1.02, -0.010), +] + + +def download_source(name: str, directory: pathlib.Path) -> pathlib.Path: + url, expected_digest = SOURCES[name] + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request) as response: + data = response.read() + digest = hashlib.sha256(data).hexdigest() + if digest != expected_digest: + raise ValueError(f"unexpected SHA-256 for {name}: {digest}") + path = directory / f"{name}.wav" + path.write_bytes(data) + return path + + +def read_wave(path: pathlib.Path) -> tuple[int, list[float]]: + with wave.open(str(path), "rb") as source: + if source.getnchannels() != 1: + raise ValueError(f"{path} must be mono") + sample_rate = source.getframerate() + sample_width = source.getsampwidth() + frames = source.readframes(source.getnframes()) + + if sample_width == 2: + samples = [ + value[0] / 32_768.0 for value in struct.iter_unpack(" list[float]: + return samples[round(start * sample_rate) : round(end * sample_rate)] + + +def resample( + samples: list[float], source_rate: int, speed: float = 1.0 +) -> list[float]: + output_length = round(len(samples) * SAMPLE_RATE / source_rate / speed) + output: list[float] = [] + for index in range(output_length): + position = index * source_rate * speed / SAMPLE_RATE + left = min(int(position), len(samples) - 1) + right = min(left + 1, len(samples) - 1) + fraction = position - left + output.append(samples[left] * (1.0 - fraction) + samples[right] * fraction) + return output + + +def high_pass(samples: list[float], cutoff: float) -> list[float]: + time_step = 1.0 / SAMPLE_RATE + resistance = 1.0 / (math.tau * cutoff) + alpha = resistance / (resistance + time_step) + output: list[float] = [] + previous_input = 0.0 + previous_output = 0.0 + for sample in samples: + filtered = alpha * (previous_output + sample - previous_input) + output.append(filtered) + previous_input = sample + previous_output = filtered + return output + + +def remove_rumble(samples: list[float], cutoff: float) -> list[float]: + """Apply a gentle two-pole high-pass without an external DSP dependency.""" + return high_pass(high_pass(samples, cutoff), cutoff) + + +def low_pass(samples: list[float], cutoff: float) -> list[float]: + time_step = 1.0 / SAMPLE_RATE + resistance = 1.0 / (math.tau * cutoff) + alpha = time_step / (resistance + time_step) + output: list[float] = [] + previous = 0.0 + for sample in samples: + previous += alpha * (sample - previous) + output.append(previous) + return output + + +def fade(samples: list[float], fade_in: float, fade_out: float) -> list[float]: + output = samples.copy() + fade_in_samples = min(round(fade_in * SAMPLE_RATE), len(output)) + fade_out_samples = min(round(fade_out * SAMPLE_RATE), len(output)) + for index in range(fade_in_samples): + output[index] *= index / max(1, fade_in_samples) + for age in range(fade_out_samples): + output[-1 - age] *= age / max(1, fade_out_samples) + return output + + +def normalize(samples: list[float], peak_level: float) -> list[float]: peak = max(abs(sample) for sample in samples) - pcm = b"".join( - struct.pack( - " list[list[float]]: + # Four separated strikes from the slow-typing master preserve differences + # in typebar position and touch. Their relative levels remain intact. + onsets = [0.24, 0.63, 1.055, 1.29] + variants: list[list[float]] = [] + levels: list[float] = [] + for onset, tuning in zip(onsets, VARIANT_TUNING, strict=True): + speed_scale, high_scale, low_scale, level, tail_offset = tuning + samples = trim( + source, source_rate, onset - 0.012, onset + 0.216 + tail_offset ) + samples = resample(samples, source_rate, speed * speed_scale) + samples = remove_rumble(samples, high * high_scale) + samples = low_pass(samples, low * low_scale) + variants.append(fade(samples, 0.004, 0.025)) + levels.append(level) + + loudest_peak = max( + max(abs(sample) for sample in samples) * level + for samples, level in zip(variants, levels, strict=True) + ) + return [ + [sample / loudest_peak * peak * level for sample in samples] + for samples, level in zip(variants, levels, strict=True) + ] + + +def prepare_backspace(source: list[float], source_rate: int) -> list[float]: + # The recorded space bar has a natural two-stage press/release action that + # remains recognizably different from a printing-key strike. + samples = trim(source, source_rate, 0.068, 0.31) + samples = resample(samples, source_rate) + samples = remove_rumble(samples, 380.0) + samples = low_pass(samples, 7_000.0) + samples = fade(samples, 0.004, 0.03) + return normalize(samples, 0.23) + + +def mix(tracks: list[tuple[list[float], float]], duration: float) -> list[float]: + output = [0.0] * round(duration * SAMPLE_RATE) + for samples, start in tracks: + offset = round(start * SAMPLE_RATE) + for index, sample in enumerate(samples[: len(output) - offset]): + output[offset + index] += sample + return output + + +def prepare_return( + space_source: list[float], space_rate: int, bell_source: list[float], bell_rate: int +) -> list[float]: + mechanism = trim(space_source, space_rate, 0.068, 0.34) + mechanism = resample(mechanism, space_rate, 0.94) + mechanism = remove_rumble(mechanism, 300.0) + mechanism = low_pass(mechanism, 7_500.0) + mechanism = fade(mechanism, 0.004, 0.035) + mechanism = normalize(mechanism, 0.24) + + bell = trim(bell_source, bell_rate, 0.03, 0.70) + bell = resample(bell, bell_rate) + bell = remove_rumble(bell, 420.0) + bell = low_pass(bell, 12_000.0) + bell = fade(bell, 0.003, 0.06) + bell = normalize(bell, 0.3) + + combined = mix([(mechanism, 0.0), (bell, 0.13)], 0.8) + combined = fade(combined, 0.002, 0.04) + return normalize(combined, 0.42) + + +def write_wave(name: str, samples: list[float]) -> None: + pcm = b"".join( + struct.pack(" Non print(f"wrote {len(samples)} samples to {output_path}") -def key_strike() -> list[float]: - """A weighty typebar impact followed by a short key-cap return.""" - duration = 0.095 - rng = random.Random(0x7ADAC) - samples: list[float] = [] - smoothed_noise = 0.0 - - for index in range(round(SAMPLE_RATE * duration)): - t = index / SAMPLE_RATE - noise = rng.uniform(-1.0, 1.0) - smoothed_noise = smoothed_noise * 0.76 + noise * 0.24 - - # The old effect emphasized 2–4 kHz noise, which read as a light - # computer-key click. Most energy now sits in the wooden/mechanical - # body, with just enough metal at the leading edge to identify a - # typebar strike. - impact = burst( - t, 0.0, 0.0085, smoothed_noise * 0.82, 0.42, (760.0, 1_280.0) - ) - body = ( - 0.72 * math.sin(math.tau * 112 * t) - + 0.34 * math.sin(math.tau * 238 * t) - + 0.16 * math.sin(math.tau * 475 * t) - ) * math.exp(-t / 0.038) - return_click = burst( - t, 0.027, 0.007, smoothed_noise * 0.34, 0.25, (980.0, 1_620.0) - ) - sample = impact + body + return_click - - # Avoid a discontinuity at the end of the file. - if t > duration - 0.012: - sample *= (duration - t) / 0.012 - samples.append(sample) - return samples - - -def deep_strike() -> list[float]: - """A lower, cabinet-heavy typewriter with a restrained metal edge.""" - duration = 0.11 - rng = random.Random(0xDEE7) - samples: list[float] = [] - smoothed_noise = 0.0 - - for index in range(round(SAMPLE_RATE * duration)): - t = index / SAMPLE_RATE - noise = rng.uniform(-1.0, 1.0) - smoothed_noise = smoothed_noise * 0.82 + noise * 0.18 - impact = burst( - t, 0.0, 0.011, smoothed_noise * 0.55, 0.34, (540.0, 860.0) - ) - body = ( - 0.9 * math.sin(math.tau * 82 * t) - + 0.45 * math.sin(math.tau * 164 * t) - + 0.18 * math.sin(math.tau * 328 * t) - ) * math.exp(-t / 0.052) - return_click = burst( - t, 0.032, 0.009, smoothed_noise * 0.2, 0.16, (720.0, 1_080.0) - ) - sample = impact + body + return_click - if t > duration - 0.014: - sample *= (duration - t) / 0.014 - samples.append(sample) - return samples - - -def soft_strike() -> list[float]: - """A damped office typewriter with felted mechanics.""" - duration = 0.085 - rng = random.Random(0x50F7) - samples: list[float] = [] - smoothed_noise = 0.0 - - for index in range(round(SAMPLE_RATE * duration)): - t = index / SAMPLE_RATE - noise = rng.uniform(-1.0, 1.0) - smoothed_noise = smoothed_noise * 0.88 + noise * 0.12 - impact = burst( - t, 0.0, 0.013, smoothed_noise * 0.38, 0.24, (620.0, 980.0) - ) - body = ( - 0.44 * math.sin(math.tau * 126 * t) - + 0.18 * math.sin(math.tau * 252 * t) - ) * math.exp(-t / 0.035) - sample = impact + body - if t > duration - 0.012: - sample *= (duration - t) / 0.012 - samples.append(sample) - return samples - - -def soft_burst( - t: float, - start: float, - attack: float, - decay: float, - noise: float, - tone: float, - frequencies: tuple[float, float], -) -> float: - if t < start: - return 0.0 - age = t - start - envelope = (1.0 - math.exp(-age / attack)) * math.exp(-age / decay) - resonance = math.sin(math.tau * frequencies[0] * age) + 0.3 * math.sin( - math.tau * frequencies[1] * age - ) - return envelope * (noise + tone * resonance) - - -def backspace_release() -> list[float]: - """A gentle felted key release, distinct from a printing strike.""" - duration = 0.075 - rng = random.Random(0xBAC5ACE) - samples: list[float] = [] - smoothed_noise = 0.0 - - for index in range(round(SAMPLE_RATE * duration)): - t = index / SAMPLE_RATE - noise = rng.uniform(-1.0, 1.0) - smoothed_noise = smoothed_noise * 0.86 + noise * 0.14 - - # Rounded attacks and a reduced peak keep deletion audible without the - # sharp ratchet sound of a printing strike. - release = soft_burst( - t, 0.0, 0.0035, 0.016, smoothed_noise * 0.16, 0.11, (250.0, 390.0) - ) - settle = soft_burst( - t, 0.025, 0.004, 0.012, smoothed_noise * 0.1, 0.07, (310.0, 470.0) - ) - spring = ( - 0.2 * math.sin(math.tau * 68 * t) - + 0.07 * math.sin(math.tau * 136 * t) - ) * math.exp(-t / 0.032) - sample = release + settle + spring - if t > duration - 0.01: - sample *= (duration - t) / 0.01 - samples.append(sample) - return samples - - -def carriage_return() -> list[float]: - """A lever touch, quiet carriage travel, stop clack, and high margin bell.""" - duration = 0.48 - rng = random.Random(0xCA771A6E) - samples: list[float] = [] - smoothed_noise = 0.0 - rack_times = [0.058, 0.087, 0.119, 0.154, 0.183] - - for index in range(round(SAMPLE_RATE * duration)): - t = index / SAMPLE_RATE - noise = rng.uniform(-1.0, 1.0) - smoothed_noise = smoothed_noise * 0.78 + noise * 0.22 - - # The reference recording is transient-first: a short lever contact, - # followed by a mostly quiet mechanical travel interval. Avoiding a - # sustained low tone keeps this from sounding electronically looped. - lever = burst(t, 0.0, 0.006, noise * 0.34, 0.13, (290.0, 880.0)) - lever_body = 0.08 * math.sin(math.tau * 170.0 * t) * math.exp(-t / 0.018) - - if 0.035 <= t < 0.205: - travel_age = t - 0.035 - travel = ( - smoothed_noise * 0.018 - + 0.009 * math.sin(math.tau * 71.0 * travel_age) - ) * math.sin(math.pi * travel_age / 0.17) - else: - travel = 0.0 - - rack = 0.0 - for click_time in rack_times: - click_age = t - click_time - if 0.0 <= click_age < 0.006: - rack += math.exp(-click_age / 0.0015) * ( - noise * 0.055 - + 0.025 * math.sin(math.tau * 980.0 * click_age) - ) - - # The real acoustic signature arrives at the end of travel: one broad - # stop transient excites a small, high bell. Sparse inharmonic partials - # resemble metal without turning into a clean electronic chord. - stop = burst(t, 0.213, 0.012, noise * 0.5, 0.16, (240.0, 1_180.0)) - bell_age = t - 0.222 - if bell_age >= 0.0: - bell_attack = 1.0 - math.exp(-bell_age / 0.00045) - bell = bell_attack * ( - 0.5 - * math.sin(math.tau * 2_620.0 * bell_age) - * math.exp(-bell_age / 0.16) - + 0.14 - * math.sin(math.tau * 5_730.0 * bell_age + 0.45) - * math.exp(-bell_age / 0.075) - + 0.055 - * math.sin(math.tau * 10_180.0 * bell_age + 1.1) - * math.exp(-bell_age / 0.038) - ) - bell_strike = noise * 0.22 * math.exp(-bell_age / 0.006) - else: - bell = 0.0 - bell_strike = 0.0 - - sample = lever + lever_body + travel + rack + stop + bell_strike + bell - if t > duration - 0.025: - sample *= (duration - t) / 0.025 - samples.append(sample) - return samples - - def main() -> None: - write_wave("typewriter-key.wav", key_strike()) - write_wave("typewriter-key-deep.wav", deep_strike()) - write_wave("typewriter-key-soft.wav", soft_strike(), peak_level=0.72) - write_wave("typewriter-backspace.wav", backspace_release(), peak_level=0.32) - write_wave("typewriter-return.wav", carriage_return(), peak_level=0.58) + with tempfile.TemporaryDirectory(prefix="termleaf-audio-") as temporary: + directory = pathlib.Path(temporary) + typing_rate, typing_source = read_wave(download_source("typing", directory)) + space_rate, space_source = read_wave(download_source("space", directory)) + bell_rate, bell_source = read_wave(download_source("bell", directory)) + + profiles = { + "typewriter-key": prepare_key_variants( + typing_source, + typing_rate, + speed=1.0, + high=300.0, + low=10_500.0, + peak=0.46, + ), + "typewriter-key-deep": prepare_key_variants( + typing_source, + typing_rate, + speed=0.9, + high=280.0, + low=6_000.0, + peak=0.42, + ), + "typewriter-key-soft": prepare_key_variants( + typing_source, + typing_rate, + speed=0.97, + high=390.0, + low=3_600.0, + peak=0.28, + ), + } + for base_name, variants in profiles.items(): + for index, samples in enumerate(variants, start=1): + suffix = "" if index == 1 else f"-{index}" + write_wave(f"{base_name}{suffix}.wav", samples) + write_wave("typewriter-backspace.wav", prepare_backspace(space_source, space_rate)) + write_wave( + "typewriter-return.wav", + prepare_return(space_source, space_rate, bell_source, bell_rate), + ) if __name__ == "__main__":