From 8c88be245b229f45ca17574e01cf371d021bfc4e Mon Sep 17 00:00:00 2001 From: Isa Goksu Date: Sun, 29 Mar 2026 23:28:16 +0100 Subject: [PATCH 1/5] Fixed shortcuts issues --- Cargo.lock | 128 ++++++++++ Cargo.toml | 3 +- src/main.rs | 711 ++++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 674 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08d6266..72cbaf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -657,6 +666,7 @@ dependencies = [ "crossterm 0.29.0", "ratatui", "rodio", + "timestretch", ] [[package]] @@ -698,6 +708,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -709,6 +728,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -813,6 +841,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -924,6 +961,20 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.38.44" @@ -977,6 +1028,49 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1025,6 +1119,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.11.1" @@ -1133,6 +1233,18 @@ dependencies = [ "syn", ] +[[package]] +name = "timestretch" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f2be302681be6e361b28d6275a9915d70f836a39fab3d19adaa39fad01c8f5" +dependencies = [ + "arc-swap", + "rustfft", + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.9.0" @@ -1165,6 +1277,16 @@ dependencies = [ "winnow", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1595,3 +1717,9 @@ checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags 2.9.1", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 5a1f894..2e01cee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ edition = "2024" [dependencies] rodio = "0.20" crossterm = "0.29" -ratatui = "0.29" \ No newline at end of file +ratatui = "0.29" +timestretch = "0.4" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 57eb1a2..22bd025 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use std::{ fs, io, path::PathBuf, + sync::mpsc, sync::{Arc, Mutex}, time::{Duration, Instant}, }; @@ -19,6 +20,64 @@ use ratatui::{ widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph}, }; use rodio::{Decoder, OutputStream, Sink, Source}; +use timestretch::{StretchParams, stretch, QualityMode}; + +struct VecSource { + data: Vec, + pos: usize, + channels: u16, + sample_rate: u32, + duration: Option, +} + +impl VecSource { + fn new(data: Vec, channels: u16, sample_rate: u32) -> Self { + let total_samples = data.len() as u64; + let channels_u64 = channels as u64; + let sample_rate_u64 = sample_rate as u64; + let duration = if channels_u64 > 0 && sample_rate_u64 > 0 { + Some(Duration::from_secs_f64(total_samples as f64 / (channels_u64 * sample_rate_u64) as f64)) + } else { + None + }; + VecSource { + data, + pos: 0, + channels, + sample_rate, + duration, + } + } +} + +impl Iterator for VecSource { + type Item = f32; + + fn next(&mut self) -> Option { + if self.pos < self.data.len() { + let sample = self.data[self.pos]; + self.pos += 1; + Some(sample) + } else { + None + } + } +} + +impl Source for VecSource { + fn current_frame_len(&self) -> Option { + Some((self.data.len() - self.pos) / self.channels as usize) + } + fn channels(&self) -> u16 { + self.channels + } + fn sample_rate(&self) -> u32 { + self.sample_rate + } + fn total_duration(&self) -> Option { + self.duration + } +} #[derive(Clone)] struct Song { @@ -48,6 +107,13 @@ struct Player { search_query: String, filtered_songs: Vec, g_pressed: bool, + playback_rate: f32, + decoded_samples: Option>, + decoded_channels: u16, + decoded_sample_rate: u32, + stretch_rx: Option, f32)>>, + pending_stretched: Option>, + playing_stretched: bool, } impl Player { @@ -64,8 +130,8 @@ impl Player { let _ = execute!(io::stdout(), SetTitle(&title)); } - fn new() -> Result> { - let songs = load_mp3_files()?; + fn new(music_dir: Option) -> Result> { + let songs = load_mp3_files(music_dir)?; if songs.is_empty() { return Err("No MP3 files found".into()); } @@ -98,7 +164,7 @@ impl Player { }; let filtered_songs: Vec = (0..songs.len()).collect(); - + let player = Player { songs, current_index: 0, @@ -118,6 +184,13 @@ impl Player { search_query: String::new(), filtered_songs, g_pressed: false, + playback_rate: 1.0, + decoded_samples: None, + decoded_channels: 0, + decoded_sample_rate: 0, + stretch_rx: None, + pending_stretched: None, + playing_stretched: false, }; // Set initial terminal title @@ -139,37 +212,46 @@ impl Player { self.selected_index = index; self.list_state.select(Some(self.selected_index)); self.seek_offset = Duration::from_secs(0); - if let Some(ref sink) = self.sink { + + let should_stretch = (self.playback_rate - 1.0).abs() >= 0.01; + + let sink = self.sink.clone(); + if let Some(sink) = sink { let song = &self.songs[index]; match std::fs::File::open(&song.path) { - Ok(file) => { - match Decoder::new(file) { - Ok(source) => { - // Try to get duration from the source - let total_duration = source.total_duration(); - - let sink = sink.lock().unwrap(); - sink.stop(); - - // If we have a seek offset, we need to skip ahead - if self.seek_offset > Duration::from_secs(0) { - let skipped_source = source.skip_duration(self.seek_offset); - sink.append(skipped_source); - } else { - sink.append(source); - } - - sink.play(); - self.is_playing = true; - self.playback_start = Some(Instant::now()); - self.song_duration = total_duration; - self.update_terminal_title(); - } - Err(e) => { - eprintln!("Warning: Could not decode audio file '{}': {e}", song.name); - } + Ok(file) => match Decoder::new(file) { + Ok(source) => { + let total_duration = source.total_duration(); + let sr = source.sample_rate(); + let ch = source.channels(); + + let raw_samples: Vec = source.convert_samples::().collect(); + self.decoded_samples = Some(raw_samples.clone()); + self.decoded_channels = ch; + self.decoded_sample_rate = sr; + + self.stretch_rx = None; + self.pending_stretched = None; + self.playing_stretched = false; + + let source = VecSource::new(raw_samples, ch, sr); + + let sink = sink.lock().unwrap(); + sink.stop(); + sink.set_speed(1.0); + sink.append(source); + sink.play(); + self.is_playing = true; + self.playback_start = Some(Instant::now()); + self.song_duration = total_duration; + self.seek_offset = Duration::from_secs(0); + drop(sink); + self.update_terminal_title(); } - } + Err(e) => { + eprintln!("Warning: Could not decode audio file '{}': {e}", song.name); + } + }, Err(e) => { eprintln!("Warning: Could not open audio file '{}': {e}", song.name); } @@ -178,33 +260,26 @@ impl Player { eprintln!("Warning: No audio sink available. Cannot play '{}'", self.songs[index].name); } + if should_stretch { + self.spawn_stretch(); + } + Ok(()) } fn play_or_pause(&mut self) -> Result<(), Box> { - // If no songs are loaded, do nothing if self.songs.is_empty() { return Ok(()); } - // If no song has ever been played (initial state), play the selected song - if self.playback_start.is_none() && !self.is_playing { - self.play_song(self.selected_index)?; - return Ok(()); - } - - // If selected song is different from current playing song, play the selected song - if self.selected_index != self.current_index { + if self.selected_index != self.current_index || self.decoded_samples.is_none() { self.play_song(self.selected_index)?; + } else if self.is_playing { + self.pause_playback(); + self.update_terminal_title(); } else { - // If selected song is the same as current playing song, toggle play/pause - if self.is_playing { - self.pause_playback(); - self.update_terminal_title(); - } else { - self.resume_playback(); - self.update_terminal_title(); - } + self.resume_playback(); + self.update_terminal_title(); } Ok(()) } @@ -278,12 +353,7 @@ impl Player { } fn get_playback_progress(&self) -> (Duration, Option) { - if let Some(start_time) = self.playback_start { - let elapsed = start_time.elapsed() + self.seek_offset; - (elapsed, self.song_duration) - } else { - (self.seek_offset, self.song_duration) - } + (self.current_song_position(), self.song_duration) } fn format_duration(duration: Duration) -> String { @@ -295,10 +365,8 @@ impl Player { fn pause_playback(&mut self) { if self.is_playing { - // Store current progress before pausing - if let Some(start_time) = self.playback_start { - self.seek_offset += start_time.elapsed(); - } + // Store current song position before pausing + self.seek_offset = self.current_song_position(); if let Some(ref sink) = self.sink { let sink = sink.lock().unwrap(); @@ -312,6 +380,10 @@ impl Player { fn resume_playback(&mut self) { if !self.is_playing && !self.songs.is_empty() { + if let Some(stretched) = self.pending_stretched.take() { + self.apply_stretched(stretched); + return; + } if let Some(ref sink) = self.sink { let sink = sink.lock().unwrap(); sink.play(); @@ -323,70 +395,288 @@ impl Player { } fn seek(&mut self, offset_seconds: i32) { - if !self.songs.is_empty() && self.is_playing { - if let Some(ref sink) = self.sink { - // Get current actual position (including elapsed time since playback start) - let current_position = if let Some(start_time) = self.playback_start { - self.seek_offset + start_time.elapsed() - } else { - self.seek_offset - }; - - let seek_duration = Duration::from_secs(offset_seconds.unsigned_abs().into()); - let new_position = if offset_seconds < 0 { - // Seek backward - if current_position > seek_duration { - current_position - seek_duration - } else { - Duration::from_secs(0) + if self.songs.is_empty() { + return; + } + + let current_position = self.current_song_position(); + let seek_duration = Duration::from_secs(offset_seconds.unsigned_abs().into()); + let new_position = if offset_seconds < 0 { + if current_position > seek_duration { + current_position - seek_duration + } else { + Duration::from_secs(0) + } + } else { + current_position + seek_duration + }; + + // Determine which samples to use: stretched or original + let samples = if let Some(ref raw) = self.decoded_samples { + raw.clone() + } else { + return; + }; + + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + + if let Some(ref sink_arc) = self.sink { + let source = VecSource::new(samples, ch, sr); + let skipped = source.skip_duration(new_position); + + let sink = sink_arc.lock().unwrap(); + sink.stop(); + sink.set_speed(1.0); + sink.append(skipped); + sink.play(); + drop(sink); + + self.seek_offset = new_position; + self.playback_start = Some(Instant::now()); + self.playing_stretched = false; + self.is_playing = true; + + // If we had a non-1.0 rate, re-trigger stretch from new position + if (self.playback_rate - 1.0).abs() >= 0.01 { + self.spawn_stretch(); + // Pause and wait for stretch like change_playback_rate does + if let Some(ref sink) = self.sink { + let sink = sink.lock().unwrap(); + sink.pause(); + } + self.is_playing = false; + } + } + } + + fn change_playback_rate(&mut self, delta: f32) { + let new_rate = (self.playback_rate + delta).clamp(0.25, 4.0); + let new_rate = (new_rate * 100.0).round() / 100.0; + + // Save current song position before changing rate + let song_pos = self.current_song_position(); + self.seek_offset = song_pos; + self.playback_start = None; + + self.playback_rate = new_rate; + self.playing_stretched = false; + + // Pause audio while stretch computes — avoids chipmunk/horror pitch artifacts + if let Some(ref sink) = self.sink { + let sink = sink.lock().unwrap(); + sink.pause(); + } + self.is_playing = false; + + self.spawn_stretch(); + } + + fn reset_playback_rate(&mut self) { + let song_pos = self.current_song_position(); + let was_stretched = self.playing_stretched; + let was_stretching = self.stretch_rx.is_some(); + + self.playback_rate = 1.0; + self.stretch_rx = None; + self.pending_stretched = None; + self.playing_stretched = false; + + if was_stretched || was_stretching { + // Reload original samples at current position and resume + if let Some(raw) = self.decoded_samples.clone() { + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + if let Some(ref sink_arc) = self.sink { + let source = VecSource::new(raw, ch, sr); + let skipped = source.skip_duration(song_pos); + let sink = sink_arc.lock().unwrap(); + sink.stop(); + sink.set_speed(1.0); + sink.append(skipped); + sink.play(); + drop(sink); + self.seek_offset = song_pos; + self.playback_start = Some(Instant::now()); + self.is_playing = true; + } + } + } + // If not stretched and not stretching, audio is already playing original at speed 1.0 + } + + fn spawn_stretch(&mut self) { + if let (Some(raw), Some(_sink)) = (&self.decoded_samples, &self.sink) { + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + let rate = self.playback_rate; + + if (rate - 1.0).abs() < 0.01 { + self.stretch_rx = None; + self.pending_stretched = None; + return; + } + + // Only stretch a chunk from current position (60s worth of audio) + // to keep processing fast. Full songs can be millions of samples. + let samples_per_sec = sr as usize * ch as usize; + let song_pos = self.seek_offset; + let start_sample = (song_pos.as_secs_f64() * samples_per_sec as f64) as usize; + let chunk_samples = samples_per_sec * 60; // 60 seconds + let end_sample = (start_sample + chunk_samples).min(raw.len()); + let chunk = raw[start_sample..end_sample].to_vec(); + + if chunk.is_empty() { + return; + } + + let (tx, rx) = mpsc::channel(); + self.stretch_rx = Some(rx); + self.pending_stretched = None; + + std::thread::spawn(move || { + let ratio = 1.0 / rate as f64; + let params = StretchParams::new(ratio) + .with_sample_rate(sr) + .with_channels(ch as u32) + .with_quality_mode(QualityMode::LowLatency) + .with_envelope_preservation(false) + .with_multi_resolution(false) + .with_band_split(false) + .with_elastic_timing(false) + .with_normalize(true); + + match stretch(&chunk, ¶ms) { + Ok(s) if !s.is_empty() => { + let _ = tx.send((s, rate)); } - } else { - // Seek forward - current_position + seek_duration - }; + _ => { + // Stretch failed — signal failure + let _ = tx.send((Vec::new(), rate)); + } + } + }); + } + } - // Try to seek using rodio's try_seek method - let sink = sink.lock().unwrap(); - match sink.try_seek(new_position) { - Ok(()) => { - // Seeking succeeded, update our tracking variables - self.seek_offset = new_position; - self.playback_start = Some(Instant::now()); + fn check_stretch_result(&mut self) { + if let Some(ref rx) = self.stretch_rx { + match rx.try_recv() { + Ok((stretched, rate)) => { + self.stretch_rx = None; + if (self.playback_rate - rate).abs() >= 0.01 { + return; } - Err(_) => { - // Seeking failed, fall back to restarting from new position - drop(sink); - self.seek_offset = new_position; - let _ = self.play_song(self.current_index); + if stretched.is_empty() { + // Stretch failed — resume original audio at current position + self.resume_original_audio(); + } else { + self.apply_stretched(stretched); } } + Err(mpsc::TryRecvError::Disconnected) => { + self.stretch_rx = None; + // Thread died — resume original audio + self.resume_original_audio(); + } + Err(mpsc::TryRecvError::Empty) => {} } } } + /// Resume playing original (un-stretched) audio from current position. + /// Used as fallback when stretch fails. + fn resume_original_audio(&mut self) { + if let Some(raw) = self.decoded_samples.clone() { + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + let song_pos = self.seek_offset; // playback_start is None when paused + if let Some(ref sink_arc) = self.sink { + let source = VecSource::new(raw, ch, sr); + let skipped = source.skip_duration(song_pos); + let sink = sink_arc.lock().unwrap(); + sink.stop(); + sink.set_speed(1.0); + sink.append(skipped); + sink.play(); + drop(sink); + self.playback_start = Some(Instant::now()); + self.is_playing = true; + self.playing_stretched = false; + } + } + } + + /// Returns the current position in the original song (accounting for playback rate). + fn current_song_position(&self) -> Duration { + let elapsed = if let Some(start_time) = self.playback_start { + start_time.elapsed() + } else { + Duration::from_secs(0) + }; + + if self.playing_stretched { + // Stretched audio plays at sink speed 1.0, but the audio itself is + // time-compressed. 1 second of wall-clock = playback_rate seconds of song. + self.seek_offset + elapsed.mul_f32(self.playback_rate) + } else { + // Original audio plays at sink speed 1.0 (no set_speed). + // 1 second of wall-clock = 1 second of song. + self.seek_offset + elapsed + } + } + + fn apply_stretched(&mut self, stretched: Vec) { + if stretched.is_empty() { + return; + } + let sink_arc = match self.sink { + Some(ref s) => s.clone(), + None => return, + }; + + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + + // The stretched chunk starts at seek_offset (set before spawning stretch). + // No need to skip — it's already trimmed to the right starting position. + let source = VecSource::new(stretched, ch, sr); + + { + let sink = sink_arc.lock().unwrap(); + sink.stop(); + sink.set_speed(1.0); + sink.append(source); + sink.play(); + } + + self.playing_stretched = true; + // seek_offset stays at the song position where the chunk starts + self.playback_start = Some(Instant::now()); + self.is_playing = true; + self.update_terminal_title(); + } + fn fuzzy_search(&mut self, query: &str) { if query.is_empty() { self.filtered_songs = (0..self.songs.len()).collect(); } else { let query_lower = query.to_lowercase(); - let mut matches: Vec<(usize, f32)> = self.songs + let mut matches: Vec<(usize, f32)> = self + .songs .iter() .enumerate() .filter_map(|(index, song)| { let song_name_lower = song.name.to_lowercase(); let score = Self::fuzzy_match_score(&query_lower, &song_name_lower); - if score > 0.0 { - Some((index, score)) - } else { - None - } + if score > 0.0 { Some((index, score)) } else { None } }) .collect(); - + matches.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); self.filtered_songs = matches.into_iter().map(|(index, _)| index).collect(); } - + if !self.filtered_songs.is_empty() { self.selected_index = self.filtered_songs[0]; self.list_state.select(Some(0)); @@ -397,25 +687,25 @@ impl Player { if query.is_empty() { return 1.0; } - + if text.contains(query) { let exact_match_bonus = if text == query { 2.0 } else { 1.5 }; let starts_with_bonus = if text.starts_with(query) { 1.2 } else { 1.0 }; return exact_match_bonus * starts_with_bonus; } - + let mut score = 0.0; let query_chars: Vec = query.chars().collect(); let text_chars: Vec = text.chars().collect(); let mut query_index = 0; - + for (text_index, text_char) in text_chars.iter().enumerate() { if query_index < query_chars.len() && *text_char == query_chars[query_index] { score += 1.0 / (text_index as f32 + 1.0); query_index += 1; } } - + if query_index == query_chars.len() { score / query_chars.len() as f32 } else { @@ -449,10 +739,7 @@ impl Player { return; } - let current_filtered_index = self.filtered_songs - .iter() - .position(|&index| index == self.selected_index) - .unwrap_or(0); + let current_filtered_index = self.filtered_songs.iter().position(|&index| index == self.selected_index).unwrap_or(0); let new_filtered_index = if direction > 0 { (current_filtered_index + 1) % self.filtered_songs.len() @@ -474,7 +761,7 @@ impl Player { if self.songs.is_empty() { return; } - + if self.search_mode { if !self.filtered_songs.is_empty() { self.selected_index = self.filtered_songs[0]; @@ -490,7 +777,7 @@ impl Player { if self.songs.is_empty() { return; } - + if self.search_mode { if !self.filtered_songs.is_empty() { let last_index = self.filtered_songs.len() - 1; @@ -504,30 +791,19 @@ impl Player { } } -fn load_mp3_files() -> Result, Box> { +fn load_mp3_files(music_dir: Option) -> Result, Box> { let mut songs = Vec::new(); - // Try multiple directories in order of preference - let potential_dirs = vec![ - { - // User's Music directory - let home_dir = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - PathBuf::from(format!("{home_dir}/Music")) - }, - PathBuf::from("./data"), - ]; - - for data_dir in potential_dirs { - if data_dir.exists() { - match visit_dir(&data_dir, &mut songs) { - Ok(_) => { - //eprintln!("Loaded {} MP3 files from: {data_dir:?}", songs.len()); // break; - } - Err(e) => { - eprintln!("Warning: Could not access directory {data_dir:?}: {e}"); - continue; - } - } + if let Some(dir) = music_dir { + if dir.exists() { + visit_dir(&dir, &mut songs)?; + } else { + return Err(format!("Directory not found: {}", dir.display()).into()); + } + } else { + let dir = PathBuf::from("."); + if dir.exists() { + visit_dir(&dir, &mut songs)?; } } @@ -579,7 +855,11 @@ fn ui(f: &mut Frame, player: &Player) { .iter() .enumerate() .map(|(_display_index, &(actual_index, song))| { - let playing_indicator = if actual_index == player.current_index && player.is_playing { "♪ " } else { " " }; + let playing_indicator = if actual_index == player.current_index && player.is_playing { + "♪ " + } else { + " " + }; let content = format!("{playing_indicator}{}. {}", actual_index + 1, song.name); @@ -625,7 +905,6 @@ fn ui(f: &mut Frame, player: &Player) { 0.0 }; - let progress_label_text = if let Some(duration) = total { format!(" {}/{} ", Player::format_duration(elapsed), Player::format_duration(duration)) } else { @@ -649,15 +928,23 @@ fn ui(f: &mut Frame, player: &Player) { // Status let mode_text = if player.random_mode { "RANDOM" } else { "NORMAL" }; - let song_count = if player.search_mode { + let song_count = if player.search_mode { format!("{}/{}", player.filtered_songs.len(), player.songs.len()) } else { player.songs.len().to_string() }; + let rate_text = if player.playback_rate == 1.0 && player.stretch_rx.is_none() { + String::new() + } else if player.stretch_rx.is_some() { + format!(" | Speed: {:.2}x (processing...)", player.playback_rate) + } else { + format!(" | Speed: {:.2}x", player.playback_rate) + }; + let status_content = if player.search_mode { vec![Line::from(vec![ - Span::raw(format!(" Search Mode | Songs: {} | ", song_count)), + Span::raw(format!(" Search Mode | Songs: {}{} | ", song_count, rate_text)), Span::styled("Esc", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(": Exit Search | "), Span::styled("Enter", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), @@ -665,22 +952,20 @@ fn ui(f: &mut Frame, player: &Player) { ])] } else { vec![Line::from(vec![ - Span::raw(format!(" Mode: {} | Songs: {} | ", mode_text, song_count)), + Span::raw(format!(" Mode: {} | Songs: {}{} | ", mode_text, song_count, rate_text)), Span::styled("/", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(": Search | "), - Span::styled("x", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), + Span::styled("?", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(": Help "), ])] }; - let status = Paragraph::new(status_content) - .alignment(Alignment::Left) - .block( - Block::default() - .borders(Borders::ALL) - .title("Status") - .border_style(Style::default().fg(PRIMARY_COLOR)), - ); + let status = Paragraph::new(status_content).alignment(Alignment::Left).block( + Block::default() + .borders(Borders::ALL) + .title("Status") + .border_style(Style::default().fg(PRIMARY_COLOR)), + ); f.render_widget(status, chunks[3]); // Controls popup @@ -724,13 +1009,25 @@ fn ui(f: &mut Frame, player: &Player) { Span::styled(" R ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(" - Toggle random mode"), ]), + Line::from(vec![ + Span::styled(" +/= ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), + Span::raw(" - Increase speed (+0.1x)"), + ]), + Line::from(vec![ + Span::styled(" - ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), + Span::raw(" - Decrease speed (-0.1x)"), + ]), + Line::from(vec![ + Span::styled(" 0 ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), + Span::raw(" - Reset speed (1.0x)"), + ]), Line::from(vec![ Span::styled(" q/Esc ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(" - Exit application"), ]), Line::from(vec![ - Span::styled(" x ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), - Span::raw(" - Close this popup"), + Span::styled(" ? ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), + Span::raw(" - Toggle this help"), ]), ]) .alignment(Alignment::Left) @@ -764,8 +1061,9 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: ratatui::prelude::Rect) -> r .split(popup_layout[1])[1] } -fn run_player() -> Result<(), Box> { - let mut player = match Player::new() { +fn run_player(music_dir: Option) -> Result<(), Box> { + let auto_play = music_dir.is_some(); + let mut player = match Player::new(music_dir) { Ok(p) => p, Err(e) => { eprintln!("Player initialization failed: {e}"); @@ -775,16 +1073,18 @@ fn run_player() -> Result<(), Box> { }; if player.songs.is_empty() { - println!("No MP3 files found in any accessible directory."); - println!("MUSIX searched for MP3 files in:"); - println!(" - ~/Music (user's music directory)"); - println!(" - ./data (current directory)"); + println!("No MP3 files found in the current directory."); println!(); - println!("To test MUSIX, you can:"); - println!("Copy MP3 files to ./data directory"); + println!("Usage: musix [folder]"); + println!(" musix - play MP3s from current directory"); + println!(" musix - play MP3s from specified folder"); return Ok(()); } + if auto_play { + let _ = player.play_song(0); + } + match enable_raw_mode() { Ok(_) => {} Err(e) => { @@ -839,7 +1139,7 @@ fn main_loop(terminal: &mut Terminal>, player: &mut if key.code != KeyCode::Char('g') || key.modifiers != KeyModifiers::NONE { player.g_pressed = false; } - + match key { KeyEvent { code: KeyCode::Esc, @@ -854,7 +1154,7 @@ fn main_loop(terminal: &mut Terminal>, player: &mut break; } } - + KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, @@ -865,7 +1165,8 @@ fn main_loop(terminal: &mut Terminal>, player: &mut code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. - } | KeyEvent { + } + | KeyEvent { code: KeyCode::Char('k'), modifiers: KeyModifiers::NONE, .. @@ -881,7 +1182,8 @@ fn main_loop(terminal: &mut Terminal>, player: &mut code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. - } | KeyEvent { + } + | KeyEvent { code: KeyCode::Char('j'), modifiers: KeyModifiers::NONE, .. @@ -1053,15 +1355,56 @@ fn main_loop(terminal: &mut Terminal>, player: &mut } KeyEvent { - code: KeyCode::Char('x'), + code: KeyCode::Char('+' | '='), + modifiers: KeyModifiers::NONE, + .. + } => { + if player.search_mode { + player.search_query.push(match key.code { + KeyCode::Char('+') => '+', + _ => '=', + }); + let query = player.search_query.clone(); + player.fuzzy_search(&query); + } else { + player.change_playback_rate(0.1); + } + } + + KeyEvent { + code: KeyCode::Char('-'), + modifiers: KeyModifiers::NONE, + .. + } => { + if player.search_mode { + player.search_query.push('-'); + let query = player.search_query.clone(); + player.fuzzy_search(&query); + } else { + player.change_playback_rate(-0.1); + } + } + + KeyEvent { + code: KeyCode::Char('0'), modifiers: KeyModifiers::NONE, .. } => { if player.search_mode { - player.search_query.push('x'); + player.search_query.push('0'); let query = player.search_query.clone(); player.fuzzy_search(&query); } else { + player.reset_playback_rate(); + } + } + + KeyEvent { + code: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, + .. + } => { + if !player.search_mode { player.show_controls_popup = !player.show_controls_popup; } } @@ -1139,16 +1482,37 @@ fn main_loop(terminal: &mut Terminal>, player: &mut } } - // Check if current song finished and auto-play next - if player.is_playing { + // Check if stretch completed in background + player.check_stretch_result(); + + // Check if current song/chunk finished + if player.is_playing && player.stretch_rx.is_none() { if let Some(ref sink) = player.sink { let sink = sink.lock().unwrap(); if sink.empty() { drop(sink); - player.is_playing = false; - player.playback_start = None; - player.seek_offset = Duration::from_secs(0); - player.next_song()?; + + let song_pos = player.current_song_position(); + let song_done = match player.song_duration { + Some(dur) => song_pos >= dur, + None => true, + }; + + if player.playing_stretched && !song_done { + // Stretched chunk ended but song continues — stretch next chunk + player.seek_offset = song_pos; + player.playback_start = None; + player.playing_stretched = false; + player.is_playing = false; + player.spawn_stretch(); + } else { + // Song actually finished — play next + player.is_playing = false; + player.playback_start = None; + player.seek_offset = Duration::from_secs(0); + player.playing_stretched = false; + player.next_song()?; + } } } } @@ -1158,7 +1522,20 @@ fn main_loop(terminal: &mut Terminal>, player: &mut } fn main() { - if let Err(e) = run_player() { + let music_dir = std::env::args().nth(1).map(PathBuf::from); + + if let Some(ref dir) = music_dir { + if !dir.exists() { + eprintln!("Error: Directory not found: {}", dir.display()); + std::process::exit(1); + } + if !dir.is_dir() { + eprintln!("Error: Not a directory: {}", dir.display()); + std::process::exit(1); + } + } + + if let Err(e) = run_player(music_dir) { eprintln!("Error: {e}"); std::process::exit(1); } From e9e7d89a757bf922a3f2fa4ea8e8a91b6fd4506d Mon Sep 17 00:00:00 2001 From: Isa Goksu Date: Sun, 29 Mar 2026 23:32:52 +0100 Subject: [PATCH 2/5] feat: pitch-preserving playback speed with custom WSOLA Replace timestretch crate with ~90-line WSOLA (Waveform Similarity Overlap-Add) implementation for cleaner time-stretching. Fix seek rewinding to beginning by rebuilding VecSource instead of try_seek. Pause audio during stretch computation to avoid chipmunk artifacts. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 128 ------------------------------------------------ Cargo.toml | 3 +- src/main.rs | 137 +++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 114 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72cbaf0..08d6266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,15 +39,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "arc-swap" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" -dependencies = [ - "rustversion", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -666,7 +657,6 @@ dependencies = [ "crossterm 0.29.0", "ratatui", "rodio", - "timestretch", ] [[package]] @@ -708,15 +698,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-derive" version = "0.4.2" @@ -728,15 +709,6 @@ dependencies = [ "syn", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -841,15 +813,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "primal-check" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" -dependencies = [ - "num-integer", -] - [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -961,20 +924,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustfft" -version = "6.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" -dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "primal-check", - "strength_reduce", - "transpose", -] - [[package]] name = "rustix" version = "0.38.44" @@ -1028,49 +977,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1119,12 +1025,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - [[package]] name = "strsim" version = "0.11.1" @@ -1233,18 +1133,6 @@ dependencies = [ "syn", ] -[[package]] -name = "timestretch" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84f2be302681be6e361b28d6275a9915d70f836a39fab3d19adaa39fad01c8f5" -dependencies = [ - "arc-swap", - "rustfft", - "serde", - "serde_json", -] - [[package]] name = "tinyvec" version = "1.9.0" @@ -1277,16 +1165,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", -] - [[package]] name = "unicode-ident" version = "1.0.18" @@ -1717,9 +1595,3 @@ checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags 2.9.1", ] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2e01cee..5a1f894 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,5 +6,4 @@ edition = "2024" [dependencies] rodio = "0.20" crossterm = "0.29" -ratatui = "0.29" -timestretch = "0.4" \ No newline at end of file +ratatui = "0.29" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 22bd025..4ea60cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,109 @@ use ratatui::{ widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph}, }; use rodio::{Decoder, OutputStream, Sink, Source}; -use timestretch::{StretchParams, stretch, QualityMode}; + +/// Simple WSOLA (Waveform Similarity Overlap-Add) time-stretcher. +/// Changes playback speed without altering pitch. No FFT, no phase vocoder — +/// just windowed overlap-add with best-correlation search for clean results. +fn time_stretch_wsola(samples: &[f32], channels: u16, rate: f32) -> Vec { + let ch = channels as usize; + if ch == 0 || samples.is_empty() || (rate - 1.0).abs() < 0.001 { + return samples.to_vec(); + } + + let total_frames = samples.len() / ch; + let segment_frames = 1024; // ~23ms at 44100 Hz + let hop_out_frames = segment_frames / 2; + let hop_in_frames = ((hop_out_frames as f64) * rate as f64) as usize; + let search_frames = 128; // search range for best overlap + + if hop_in_frames == 0 || total_frames < segment_frames { + return samples.to_vec(); + } + + // Hann window + let window: Vec = (0..segment_frames) + .map(|i| { + 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / segment_frames as f32).cos()) + }) + .collect(); + + let output_frames = (total_frames as f64 / rate as f64) as usize + segment_frames; + let mut output = vec![0.0f32; output_frames * ch]; + let mut norm = vec![0.0f32; output_frames]; // normalization weights per frame + + let mut in_frame: usize = 0; + let mut out_frame: usize = 0; + + while in_frame + segment_frames <= total_frames && out_frame + segment_frames <= output_frames { + // WSOLA: find best offset within search range by cross-correlation + let best_offset = if out_frame >= hop_out_frames && in_frame > 0 { + let mut best = 0i32; + let mut best_corr = f32::NEG_INFINITY; + let range = search_frames as i32; + + for offset in -range..=range { + let candidate = in_frame as i32 + offset; + if candidate < 0 || (candidate as usize + segment_frames) > total_frames { + continue; + } + // Compute correlation over overlap region (first hop_out_frames) + let mut corr = 0.0f32; + let overlap_end = out_frame.min(output_frames); + let overlap_start = out_frame.saturating_sub(hop_out_frames); + let overlap_len = overlap_end - overlap_start; + for f in 0..overlap_len.min(hop_out_frames) { + let out_idx = overlap_start + f; + if out_idx < output_frames && norm[out_idx] > 0.0 { + let out_val = output[out_idx * ch] / norm[out_idx]; + let in_idx = candidate as usize + f; + if in_idx < total_frames { + corr += out_val * samples[in_idx * ch]; + } + } + } + if corr > best_corr { + best_corr = corr; + best = offset; + } + } + best + } else { + 0 + }; + + let actual_in = (in_frame as i32 + best_offset).max(0) as usize; + + // Overlap-add with Hann window + for f in 0..segment_frames { + let w = window[f]; + let of = out_frame + f; + if of >= output_frames || actual_in + f >= total_frames { + break; + } + for c in 0..ch { + output[of * ch + c] += samples[(actual_in + f) * ch + c] * w; + } + norm[of] += w; + } + + in_frame = (actual_in + hop_in_frames).min(total_frames); + out_frame += hop_out_frames; + } + + // Normalize + let final_frames = out_frame.min(output_frames); + for f in 0..final_frames { + if norm[f] > 0.001 { + for c in 0..ch { + output[f * ch + c] /= norm[f]; + } + } + } + + output.truncate(final_frames * ch); + output +} struct VecSource { data: Vec, @@ -507,7 +609,6 @@ impl Player { fn spawn_stretch(&mut self) { if let (Some(raw), Some(_sink)) = (&self.decoded_samples, &self.sink) { - let sr = self.decoded_sample_rate; let ch = self.decoded_channels; let rate = self.playback_rate; @@ -517,12 +618,14 @@ impl Player { return; } - // Only stretch a chunk from current position (60s worth of audio) - // to keep processing fast. Full songs can be millions of samples. + // Extract chunk from current position (60s) for fast processing + let sr = self.decoded_sample_rate; let samples_per_sec = sr as usize * ch as usize; let song_pos = self.seek_offset; let start_sample = (song_pos.as_secs_f64() * samples_per_sec as f64) as usize; - let chunk_samples = samples_per_sec * 60; // 60 seconds + // Align to channel boundary + let start_sample = (start_sample / ch as usize) * ch as usize; + let chunk_samples = samples_per_sec * 60; let end_sample = (start_sample + chunk_samples).min(raw.len()); let chunk = raw[start_sample..end_sample].to_vec(); @@ -535,25 +638,11 @@ impl Player { self.pending_stretched = None; std::thread::spawn(move || { - let ratio = 1.0 / rate as f64; - let params = StretchParams::new(ratio) - .with_sample_rate(sr) - .with_channels(ch as u32) - .with_quality_mode(QualityMode::LowLatency) - .with_envelope_preservation(false) - .with_multi_resolution(false) - .with_band_split(false) - .with_elastic_timing(false) - .with_normalize(true); - - match stretch(&chunk, ¶ms) { - Ok(s) if !s.is_empty() => { - let _ = tx.send((s, rate)); - } - _ => { - // Stretch failed — signal failure - let _ = tx.send((Vec::new(), rate)); - } + let result = time_stretch_wsola(&chunk, ch, rate); + if !result.is_empty() { + let _ = tx.send((result, rate)); + } else { + let _ = tx.send((Vec::new(), rate)); } }); } From cc4e5b78755c2564dc2a6769e6f035b9c801ea75 Mon Sep 17 00:00:00 2001 From: Isa Goksu Date: Sun, 29 Mar 2026 23:38:05 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20improve=20WSOLA=20quality=20?= =?UTF-8?q?=E2=80=94=20normalized=20correlation=20and=20cosine=20cross-fad?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Hann-window OLA with cosine cross-fade to eliminate amplitude modulation artifacts (rattling/grrhing voice). Use normalized cross-correlation for reliable waveform matching. Copy non-overlapping regions directly to preserve original waveform fidelity. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 150 +++++++++++++++++++++++++++------------------------- 1 file changed, 77 insertions(+), 73 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4ea60cd..9f23017 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,9 +21,9 @@ use ratatui::{ }; use rodio::{Decoder, OutputStream, Sink, Source}; -/// Simple WSOLA (Waveform Similarity Overlap-Add) time-stretcher. -/// Changes playback speed without altering pitch. No FFT, no phase vocoder — -/// just windowed overlap-add with best-correlation search for clean results. +/// WSOLA time-stretcher: changes speed without pitch shift. +/// Uses normalized cross-correlation for segment alignment and +/// cosine cross-fade for smooth, artifact-free transitions. fn time_stretch_wsola(samples: &[f32], channels: u16, rate: f32) -> Vec { let ch = channels as usize; if ch == 0 || samples.is_empty() || (rate - 1.0).abs() < 0.001 { @@ -31,96 +31,100 @@ fn time_stretch_wsola(samples: &[f32], channels: u16, rate: f32) -> Vec { } let total_frames = samples.len() / ch; - let segment_frames = 1024; // ~23ms at 44100 Hz - let hop_out_frames = segment_frames / 2; - let hop_in_frames = ((hop_out_frames as f64) * rate as f64) as usize; - let search_frames = 128; // search range for best overlap + let segment_frames: usize = 2048; // ~46ms at 44100 Hz — longer = smoother + let overlap_frames = segment_frames / 2; // 50% overlap for cross-fade + let hop_out = segment_frames - overlap_frames; // output advance + let hop_in = ((hop_out as f64) * rate as f64) as usize; // input advance + let search_range: usize = 256; // search ±256 frames for best match - if hop_in_frames == 0 || total_frames < segment_frames { + if hop_in == 0 || total_frames < segment_frames { return samples.to_vec(); } - // Hann window - let window: Vec = (0..segment_frames) - .map(|i| { - 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / segment_frames as f32).cos()) - }) - .collect(); - - let output_frames = (total_frames as f64 / rate as f64) as usize + segment_frames; - let mut output = vec![0.0f32; output_frames * ch]; - let mut norm = vec![0.0f32; output_frames]; // normalization weights per frame + let max_output_frames = (total_frames as f64 / rate as f64) as usize + segment_frames; + let mut output = vec![0.0f32; max_output_frames * ch]; + let mut out_len: usize = 0; + let mut in_pos: usize = 0; - let mut in_frame: usize = 0; - let mut out_frame: usize = 0; - - while in_frame + segment_frames <= total_frames && out_frame + segment_frames <= output_frames { - // WSOLA: find best offset within search range by cross-correlation - let best_offset = if out_frame >= hop_out_frames && in_frame > 0 { - let mut best = 0i32; - let mut best_corr = f32::NEG_INFINITY; - let range = search_frames as i32; + // First segment: copy directly (no cross-fade needed) + if total_frames >= segment_frames { + for f in 0..segment_frames { + for c in 0..ch { + output[f * ch + c] = samples[f * ch + c]; + } + } + out_len = segment_frames; + in_pos = hop_in; + } - for offset in -range..=range { - let candidate = in_frame as i32 + offset; - if candidate < 0 || (candidate as usize + segment_frames) > total_frames { - continue; - } - // Compute correlation over overlap region (first hop_out_frames) - let mut corr = 0.0f32; - let overlap_end = out_frame.min(output_frames); - let overlap_start = out_frame.saturating_sub(hop_out_frames); - let overlap_len = overlap_end - overlap_start; - for f in 0..overlap_len.min(hop_out_frames) { - let out_idx = overlap_start + f; - if out_idx < output_frames && norm[out_idx] > 0.0 { - let out_val = output[out_idx * ch] / norm[out_idx]; - let in_idx = candidate as usize + f; - if in_idx < total_frames { - corr += out_val * samples[in_idx * ch]; - } - } - } - if corr > best_corr { - best_corr = corr; - best = offset; + while in_pos + segment_frames <= total_frames + && out_len + hop_out + segment_frames <= max_output_frames + { + // Find best alignment: compare start of candidate segment + // with the tail of current output using normalized cross-correlation + let mut best_offset: i32 = 0; + let mut best_corr = f64::NEG_INFINITY; + let overlap_start_out = out_len - overlap_frames; + + for offset in -(search_range as i32)..=(search_range as i32) { + let candidate = in_pos as i32 + offset; + if candidate < 0 || candidate as usize + segment_frames > total_frames { + continue; + } + let cand = candidate as usize; + + let mut dot = 0.0f64; + let mut energy_a = 0.0f64; + let mut energy_b = 0.0f64; + + for f in 0..overlap_frames { + for c in 0..ch { + let a = output[(overlap_start_out + f) * ch + c] as f64; + let b = samples[(cand + f) * ch + c] as f64; + dot += a * b; + energy_a += a * a; + energy_b += b * b; } } - best - } else { - 0 - }; - let actual_in = (in_frame as i32 + best_offset).max(0) as usize; + let denom = (energy_a * energy_b).sqrt(); + let corr = if denom > 1e-10 { dot / denom } else { 0.0 }; - // Overlap-add with Hann window - for f in 0..segment_frames { - let w = window[f]; - let of = out_frame + f; - if of >= output_frames || actual_in + f >= total_frames { - break; + if corr > best_corr { + best_corr = corr; + best_offset = offset; } + } + + let actual_in = (in_pos as i32 + best_offset).max(0) as usize; + + // Cosine cross-fade over the overlap region + let xfade_start = out_len - overlap_frames; + for f in 0..overlap_frames { + let t = f as f32 / overlap_frames as f32; + let fade_out = 0.5 * (1.0 + (std::f32::consts::PI * t).cos()); + let fade_in = 1.0 - fade_out; for c in 0..ch { - output[of * ch + c] += samples[(actual_in + f) * ch + c] * w; + let old = output[(xfade_start + f) * ch + c]; + let new_val = samples[(actual_in + f) * ch + c]; + output[(xfade_start + f) * ch + c] = old * fade_out + new_val * fade_in; } - norm[of] += w; } - in_frame = (actual_in + hop_in_frames).min(total_frames); - out_frame += hop_out_frames; - } - - // Normalize - let final_frames = out_frame.min(output_frames); - for f in 0..final_frames { - if norm[f] > 0.001 { + // Copy the non-overlapping tail of the segment + let copy_start = overlap_frames; + let copy_end = segment_frames.min(total_frames - actual_in); + for f in copy_start..copy_end { for c in 0..ch { - output[f * ch + c] /= norm[f]; + output[(xfade_start + f) * ch + c] = samples[(actual_in + f) * ch + c]; } } + + out_len = xfade_start + copy_end; + in_pos = actual_in + hop_in; } - output.truncate(final_frames * ch); + output.truncate(out_len * ch); output } From def593d9639b269f082cc850df67aae5ecd0014c Mon Sep 17 00:00:00 2001 From: Isa Goksu Date: Sun, 29 Mar 2026 23:41:42 +0100 Subject: [PATCH 4/5] docs: update README with speed control, fix music dir docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pitch-preserving speed control feature to docs (+/-/= keys). Fix music directory documentation — specify folder as arg or use current directory (removed stale ~/Music and ./data references). Remove rand from dependencies list. Fix clone URL and line count. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 70 +++++++++++++++++++++++-------------------------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index cba2524..ab0c6bd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A minimalist terminal-based MP3 music player built with Rust. - **Keyboard-Driven**: Lightning-fast keyboard-only interface - **Fuzzy Search**: Real-time search with `/` key - find songs instantly - **Vim-Style Navigation**: Full vim keybinding support (hjkl, gg/G, n/N, q) +- **Pitch-Preserving Speed Control**: Speed up or slow down playback without chipmunk/horror voice effects (custom WSOLA algorithm) ## Quick Start @@ -31,7 +32,7 @@ A minimalist terminal-based MP3 music player built with Rust. ```bash # Clone the repository -git clone git@github.com:coolcode/musix.git +git clone git@github.com:isa/musix.git cd musix # Build and run @@ -43,31 +44,25 @@ cargo build --release ``` ### Quick Usage -1. **Start the player**: `cargo run` +1. **Start the player**: `cargo run -- /path/to/music` or just `cargo run` to use current directory 2. **Navigate**: Use `j/k` or arrow keys to browse songs 3. **Search**: Press `/` and type to find songs instantly 4. **Play**: Press `Enter` or `Space` to play selected song -5. **Jump**: Use `gg` (first song) or `G` (last song) -6. **Help**: Press `x` to see all controls -7. **Quit**: Press `q` or `Esc` to exit +5. **Speed up**: Press `+`/`-` to adjust playback speed +6. **Jump**: Use `gg` (first song) or `G` (last song) +7. **Help**: Press `x` to see all controls +8. **Quit**: Press `q` or `Esc` to exit -### Setup Music Files +### Music Files -MUSIX automatically searches for MP3 files in these directories: - -1. **`~/Music`** - Your system's Music directory -2. **`./data`** - Local data folder +MUSIX scans for MP3 files recursively: ```bash -# Option 1: Use local data folder -mkdir -p ./data -cp /path/to/your/music/*.mp3 ./data/ - -# Option 2: Use system Music directory -cp /path/to/your/music/*.mp3 ~/Music/ +# Play from a specific folder +musix /path/to/music -# Option 3: Create symbolic link -ln -s /path/to/your/music ./data +# Play from current directory (default) +musix ``` ## Controls @@ -93,6 +88,8 @@ ln -s /path/to/your/music ./data | `gg` / `G` | Jump to first/last song | | `,` / `.` | Seek backward/forward 5 seconds | | `<` / `>` | Same as above | +| `+` / `-` | Increase/decrease playback speed (±0.1x) | +| `=` | Reset speed to 1.0x | | `r` | Toggle Random mode | ### Search Mode @@ -140,6 +137,8 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: │ / - Enter search mode │ │ n/N - Next/prev search │ │ ,/. - Seek ±5 seconds │ +│ +/- - Speed up/down │ +│ = - Reset speed (1.0x) │ │ r - Toggle random mode │ │ q/Esc - Exit application │ │ x - Close this popup │ @@ -158,6 +157,12 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: **Example**: Searching "btl" will match "Battle Song", "Beautiful", "Subtitle" +### Playback Speed Control +- **Pitch-Preserving**: Speed changes use a custom WSOLA algorithm — voice sounds natural at any speed +- **Range**: 0.25x to 4.0x in 0.1x increments +- **Quick Reset**: Press `=` to instantly return to normal speed +- **Status Display**: Current speed shown in the status bar (e.g., "Speed: 1.50x") + ### Visual Indicators - **`→`** Currently selected song in the list - **`♪`** Currently playing song indicator @@ -192,7 +197,6 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: - **`rodio`** - Professional audio playback and MP3 decoding - **`ratatui`** - Modern terminal user interface framework - **`crossterm`** - Cross-platform terminal control -- **`rand`** - Cryptographically secure random shuffle ## Development @@ -201,7 +205,7 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: ``` musix/ ├── src/ -│ └── main.rs # Complete application (~700 lines) +│ └── main.rs # Complete application (~1600 lines) ├── data/ # MP3 files (optional) ├── .github/workflows/ # CI/CD automation ├── Cargo.toml # Dependencies and metadata @@ -230,33 +234,17 @@ cargo fmt --all -- --check ### No Music Files Found -**Issue**: `No MP3 files found in any accessible directory` +**Issue**: `No MP3 files found` **Solutions**: ```bash -# Option 1: Copy files to data folder -mkdir -p ./data -cp /path/to/your/music/*.mp3 ./data/ - -# Option 2: Create symbolic link -ln -s /path/to/your/music ./data +# Specify a directory containing MP3 files +musix /path/to/your/music -# Option 3: Check permissions -ls -la ~/Music +# Or run from a directory that has MP3 files +cd /path/to/your/music && musix ``` -### macOS Music Access - -**Issue**: Cannot access ~/Music directory on macOS - -**Solution**: Enable Full Disk Access for your terminal: - -1. **System Settings** → **Privacy & Security** → **Full Disk Access** -2. Click lock icon to unlock settings -3. Click **+** and add your terminal app (Terminal/iTerm2) -4. Enable the checkbox -5. **Restart your terminal** - ### Linux Audio Issues **Issue**: No audio output or initialization errors From c672b4e78a31e6d63d2fddec2350e80b5f92ae2e Mon Sep 17 00:00:00 2001 From: Isa Goksu Date: Mon, 30 Mar 2026 02:01:50 +0100 Subject: [PATCH 5/5] feat: seek clamping, progress bar layout, percentage jump, chunk prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp seek to song duration — can't skip past end of track - Split progress bar into [gauge | time] so they don't overlap - Add 1-9 keys to jump to 10%-90% of song - Pre-fetch next 60s chunk while current plays — seamless transitions - Fix auto-skip at ~1min: compute song duration from decoded samples (rodio Decoder::total_duration() returns None for most MP3s) - Refactor seek into reusable seek_to_position method - Update README with correct keybindings (? for help, 0 for reset, 1-9) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 49 ++++---- src/main.rs | 352 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 273 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index ab0c6bd..0db0d49 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ cargo build --release 2. **Navigate**: Use `j/k` or arrow keys to browse songs 3. **Search**: Press `/` and type to find songs instantly 4. **Play**: Press `Enter` or `Space` to play selected song -5. **Speed up**: Press `+`/`-` to adjust playback speed +5. **Speed up**: Press `+`/`-` to adjust playback speed, `0` to reset 6. **Jump**: Use `gg` (first song) or `G` (last song) -7. **Help**: Press `x` to see all controls +7. **Help**: Press `?` to see all controls 8. **Quit**: Press `q` or `Esc` to exit ### Music Files @@ -67,7 +67,7 @@ musix ## Controls -> **Tip**: Press **x** anytime to view the interactive controls popup! +> **Tip**: Press **?** anytime to view the interactive controls popup! ### Essential Keys @@ -75,7 +75,7 @@ musix |-----|--------| | **`Space/↵`** | **Smart Play** - Play selected song or pause current | | **`/`** | **Search Mode** - Enter fuzzy search | -| **`x`** | **Show/Hide help popup** | +| **`?`** | **Show/Hide help popup** | | **`q/Esc`** | **Exit** | ### Navigation & Playback @@ -88,8 +88,10 @@ musix | `gg` / `G` | Jump to first/last song | | `,` / `.` | Seek backward/forward 5 seconds | | `<` / `>` | Same as above | -| `+` / `-` | Increase/decrease playback speed (±0.1x) | -| `=` | Reset speed to 1.0x | +| `+` or `=` | Increase playback speed (+0.1x) | +| `-` | Decrease playback speed (-0.1x) | +| `0` | Reset speed to 1.0x | +| `1`-`9` | Jump to 10%-90% of song | | `r` | Toggle Random mode | ### Search Mode @@ -124,25 +126,27 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: └─────────────────────────────────┘ ``` -### Interactive Controls Popup (Press **x**) +### Interactive Controls Popup (Press **?**) ``` -┌─────────────────────────────────┐ -│ CONTROLS │ -│ │ +┌──────────────────────────────────┐ +│ CONTROLS │ +│ │ │ ↑/↓ or j/k - Navigate songs │ -│ Space/↵ - Play/Pause │ +│ Space/↵ - Play/Pause │ │ ←/→ or h/l - Play prev/next song│ -│ gg/G - Jump to first/last │ -│ / - Enter search mode │ -│ n/N - Next/prev search │ -│ ,/. - Seek ±5 seconds │ -│ +/- - Speed up/down │ -│ = - Reset speed (1.0x) │ -│ r - Toggle random mode │ -│ q/Esc - Exit application │ -│ x - Close this popup │ -└─────────────────────────────────┘ +│ gg/G - Jump to first/last │ +│ / - Enter search mode │ +│ n/N - Next/prev search │ +│ ,/. - Seek ±5 seconds │ +│ R - Toggle random mode │ +│ +/= - Increase speed │ +│ - - Decrease speed │ +│ 0 - Reset speed (1.0x) │ +│ 1-9 - Jump to 10%-90% │ +│ q/Esc - Exit application │ +│ ? - Toggle this help │ +└──────────────────────────────────┘ ``` ## Smart Features @@ -160,7 +164,7 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: ### Playback Speed Control - **Pitch-Preserving**: Speed changes use a custom WSOLA algorithm — voice sounds natural at any speed - **Range**: 0.25x to 4.0x in 0.1x increments -- **Quick Reset**: Press `=` to instantly return to normal speed +- **Quick Reset**: Press `0` to instantly return to normal speed - **Status Display**: Current speed shown in the status bar (e.g., "Speed: 1.50x") ### Visual Indicators @@ -206,7 +210,6 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: musix/ ├── src/ │ └── main.rs # Complete application (~1600 lines) -├── data/ # MP3 files (optional) ├── .github/workflows/ # CI/CD automation ├── Cargo.toml # Dependencies and metadata ├── rustfmt.toml # Code formatting rules diff --git a/src/main.rs b/src/main.rs index 9f23017..bdb7c41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,10 @@ struct Player { stretch_rx: Option, f32)>>, pending_stretched: Option>, playing_stretched: bool, + next_chunk: Option>, + next_chunk_rx: Option, f32)>>, + /// The song position where the current stretched chunk ends (in original time) + chunk_end_pos: Duration, } impl Player { @@ -297,6 +301,9 @@ impl Player { stretch_rx: None, pending_stretched: None, playing_stretched: false, + next_chunk: None, + next_chunk_rx: None, + chunk_end_pos: Duration::from_secs(0), }; // Set initial terminal title @@ -332,6 +339,15 @@ impl Player { let ch = source.channels(); let raw_samples: Vec = source.convert_samples::().collect(); + // Compute duration from decoded samples (more reliable than decoder metadata for MP3) + let computed_duration = if sr > 0 && ch > 0 { + Some(Duration::from_secs_f64( + raw_samples.len() as f64 / (sr as f64 * ch as f64), + )) + } else { + None + }; + self.decoded_samples = Some(raw_samples.clone()); self.decoded_channels = ch; self.decoded_sample_rate = sr; @@ -339,6 +355,8 @@ impl Player { self.stretch_rx = None; self.pending_stretched = None; self.playing_stretched = false; + self.next_chunk = None; + self.next_chunk_rx = None; let source = VecSource::new(raw_samples, ch, sr); @@ -349,7 +367,7 @@ impl Player { sink.play(); self.is_playing = true; self.playback_start = Some(Instant::now()); - self.song_duration = total_duration; + self.song_duration = total_duration.or(computed_duration); self.seek_offset = Duration::from_secs(0); drop(sink); self.update_terminal_title(); @@ -508,52 +526,12 @@ impl Player { let current_position = self.current_song_position(); let seek_duration = Duration::from_secs(offset_seconds.unsigned_abs().into()); let new_position = if offset_seconds < 0 { - if current_position > seek_duration { - current_position - seek_duration - } else { - Duration::from_secs(0) - } + current_position.saturating_sub(seek_duration) } else { current_position + seek_duration }; - // Determine which samples to use: stretched or original - let samples = if let Some(ref raw) = self.decoded_samples { - raw.clone() - } else { - return; - }; - - let sr = self.decoded_sample_rate; - let ch = self.decoded_channels; - - if let Some(ref sink_arc) = self.sink { - let source = VecSource::new(samples, ch, sr); - let skipped = source.skip_duration(new_position); - - let sink = sink_arc.lock().unwrap(); - sink.stop(); - sink.set_speed(1.0); - sink.append(skipped); - sink.play(); - drop(sink); - - self.seek_offset = new_position; - self.playback_start = Some(Instant::now()); - self.playing_stretched = false; - self.is_playing = true; - - // If we had a non-1.0 rate, re-trigger stretch from new position - if (self.playback_rate - 1.0).abs() >= 0.01 { - self.spawn_stretch(); - // Pause and wait for stretch like change_playback_rate does - if let Some(ref sink) = self.sink { - let sink = sink.lock().unwrap(); - sink.pause(); - } - self.is_playing = false; - } - } + self.seek_to_position(new_position); } fn change_playback_rate(&mut self, delta: f32) { @@ -611,45 +589,62 @@ impl Player { // If not stretched and not stretching, audio is already playing original at speed 1.0 } + /// Stretch a 60s chunk starting from `self.seek_offset`, store receiver in `stretch_rx`. fn spawn_stretch(&mut self) { - if let (Some(raw), Some(_sink)) = (&self.decoded_samples, &self.sink) { - let ch = self.decoded_channels; - let rate = self.playback_rate; + self.stretch_rx = self.spawn_stretch_at(self.seek_offset); + self.pending_stretched = None; + self.next_chunk = None; + self.next_chunk_rx = None; + } - if (rate - 1.0).abs() < 0.01 { - self.stretch_rx = None; - self.pending_stretched = None; - return; - } + /// Spawn a stretch thread for the chunk starting at `start_pos`. + /// Returns the receiver, or None if nothing to stretch. + fn spawn_stretch_at(&self, start_pos: Duration) -> Option, f32)>> { + let raw = self.decoded_samples.as_ref()?; + let _ = self.sink.as_ref()?; + let ch = self.decoded_channels; + let rate = self.playback_rate; - // Extract chunk from current position (60s) for fast processing - let sr = self.decoded_sample_rate; - let samples_per_sec = sr as usize * ch as usize; - let song_pos = self.seek_offset; - let start_sample = (song_pos.as_secs_f64() * samples_per_sec as f64) as usize; - // Align to channel boundary - let start_sample = (start_sample / ch as usize) * ch as usize; - let chunk_samples = samples_per_sec * 60; - let end_sample = (start_sample + chunk_samples).min(raw.len()); - let chunk = raw[start_sample..end_sample].to_vec(); - - if chunk.is_empty() { - return; + if (rate - 1.0).abs() < 0.01 { + return None; + } + + let sr = self.decoded_sample_rate; + let samples_per_sec = sr as usize * ch as usize; + let start_sample = (start_pos.as_secs_f64() * samples_per_sec as f64) as usize; + let start_sample = (start_sample / ch as usize) * ch as usize; + let chunk_samples = samples_per_sec * 60; + let end_sample = (start_sample + chunk_samples).min(raw.len()); + + if start_sample >= raw.len() { + return None; + } + + let chunk = raw[start_sample..end_sample].to_vec(); + if chunk.is_empty() { + return None; + } + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = time_stretch_wsola(&chunk, ch, rate); + if !result.is_empty() { + let _ = tx.send((result, rate)); + } else { + let _ = tx.send((Vec::new(), rate)); } + }); - let (tx, rx) = mpsc::channel(); - self.stretch_rx = Some(rx); - self.pending_stretched = None; + Some(rx) + } - std::thread::spawn(move || { - let result = time_stretch_wsola(&chunk, ch, rate); - if !result.is_empty() { - let _ = tx.send((result, rate)); - } else { - let _ = tx.send((Vec::new(), rate)); - } - }); + /// Pre-fetch the next chunk so it's ready when the current one ends. + fn prefetch_next_chunk(&mut self) { + if self.next_chunk.is_some() || self.next_chunk_rx.is_some() { + return; // already prefetching or ready } + let next_pos = self.chunk_end_pos; + self.next_chunk_rx = self.spawn_stretch_at(next_pos); } fn check_stretch_result(&mut self) { @@ -677,6 +672,30 @@ impl Player { } } + /// Check if the pre-fetched next chunk is ready. + fn check_next_chunk(&mut self) { + if self.next_chunk.is_some() { + return; // already have it + } + if let Some(ref rx) = self.next_chunk_rx { + match rx.try_recv() { + Ok((stretched, rate)) => { + self.next_chunk_rx = None; + if (self.playback_rate - rate).abs() >= 0.01 { + return; + } + if !stretched.is_empty() { + self.next_chunk = Some(stretched); + } + } + Err(mpsc::TryRecvError::Disconnected) => { + self.next_chunk_rx = None; + } + Err(mpsc::TryRecvError::Empty) => {} + } + } + } + /// Resume playing original (un-stretched) audio from current position. /// Used as fallback when stretch fails. fn resume_original_audio(&mut self) { @@ -700,6 +719,74 @@ impl Player { } } + /// Seek to a percentage of the song (1-9 = 10%-90%). + fn seek_to_percentage(&mut self, pct: u32) { + if self.songs.is_empty() || self.decoded_samples.is_none() { + return; + } + let total = self.get_song_duration_computed(); + if total.as_secs() == 0 { + return; + } + let target = total.mul_f32(pct as f32 / 10.0); + self.seek_to_position(target); + } + + /// Seek to an absolute position in the song. + fn seek_to_position(&mut self, position: Duration) { + let max_pos = self.get_song_duration_computed(); + let new_position = position.min(max_pos); + + let samples = if let Some(ref raw) = self.decoded_samples { + raw.clone() + } else { + return; + }; + + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + + if let Some(ref sink_arc) = self.sink { + let source = VecSource::new(samples, ch, sr); + let skipped = source.skip_duration(new_position); + + let sink = sink_arc.lock().unwrap(); + sink.stop(); + sink.set_speed(1.0); + sink.append(skipped); + sink.play(); + drop(sink); + + self.seek_offset = new_position; + self.playback_start = Some(Instant::now()); + self.playing_stretched = false; + self.is_playing = true; + self.next_chunk = None; + self.next_chunk_rx = None; + + if (self.playback_rate - 1.0).abs() >= 0.01 { + self.spawn_stretch(); + if let Some(ref sink) = self.sink { + let sink = sink.lock().unwrap(); + sink.pause(); + } + self.is_playing = false; + } + } + } + + /// Reliable song duration from decoded samples (or metadata fallback). + fn get_song_duration_computed(&self) -> Duration { + if let Some(ref raw) = self.decoded_samples { + let sr = self.decoded_sample_rate; + let ch = self.decoded_channels; + if sr > 0 && ch > 0 { + return Duration::from_secs_f64(raw.len() as f64 / (sr as f64 * ch as f64)); + } + } + self.song_duration.unwrap_or(Duration::from_secs(0)) + } + /// Returns the current position in the original song (accounting for playback rate). fn current_song_position(&self) -> Duration { let elapsed = if let Some(start_time) = self.playback_start { @@ -731,8 +818,6 @@ impl Player { let sr = self.decoded_sample_rate; let ch = self.decoded_channels; - // The stretched chunk starts at seek_offset (set before spawning stretch). - // No need to skip — it's already trimmed to the right starting position. let source = VecSource::new(stretched, ch, sr); { @@ -744,10 +829,14 @@ impl Player { } self.playing_stretched = true; - // seek_offset stays at the song position where the chunk starts self.playback_start = Some(Instant::now()); self.is_playing = true; + // Track where this chunk ends in original song time (seek_offset + 60s of original audio) + self.chunk_end_pos = self.seek_offset + Duration::from_secs(60); self.update_terminal_title(); + + // Start pre-fetching the next chunk immediately + self.prefetch_next_chunk(); } fn fuzzy_search(&mut self, query: &str) { @@ -986,11 +1075,11 @@ fn ui(f: &mut Frame, player: &Player) { f.render_stateful_widget(songs_list, chunks[1], &mut player.list_state.clone()); - // Progress bar + // Progress bar with separate time display let (elapsed, total) = player.get_playback_progress(); let progress_ratio = if let Some(duration) = total { - if duration.as_secs() > 0 { - (elapsed.as_secs() as f64 / duration.as_secs() as f64).min(1.0) + if duration.as_secs_f64() > 0.0 { + (elapsed.as_secs_f64() / duration.as_secs_f64()).min(1.0) } else { 0.0 } @@ -998,26 +1087,39 @@ fn ui(f: &mut Frame, player: &Player) { 0.0 }; - let progress_label_text = if let Some(duration) = total { - format!(" {}/{} ", Player::format_duration(elapsed), Player::format_duration(duration)) + let time_text = if let Some(duration) = total { + format!("{} / {}", Player::format_duration(elapsed), Player::format_duration(duration)) } else { - format!(" {} ", Player::format_duration(elapsed)) + Player::format_duration(elapsed) }; - let progress_bar_style = Style::default().fg(PRIMARY_COLOR).bg(Color::default()); - let progress_label = Span::styled(progress_label_text, progress_bar_style); + // Split progress area into [gauge | time] + let progress_block = Block::default() + .borders(Borders::ALL) + .title("Progress") + .border_style(Style::default().fg(PRIMARY_COLOR)); + let progress_inner = progress_block.inner(chunks[2]); + f.render_widget(progress_block, chunks[2]); - let progress_bar = Gauge::default() - .block( - Block::default() - .borders(Borders::ALL) - .title("Progress") - .border_style(Style::default().fg(PRIMARY_COLOR)), - ) + let time_width = time_text.len() as u16 + 2; // padding + let progress_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(10), // gauge fills remaining space + Constraint::Length(time_width), // fixed-width time display + ]) + .split(progress_inner); + + let progress_bar_style = Style::default().fg(PRIMARY_COLOR).bg(Color::default()); + let gauge = Gauge::default() .gauge_style(progress_bar_style) - .ratio(progress_ratio) - .label(progress_label); - f.render_widget(progress_bar, chunks[2]); + .ratio(progress_ratio); + f.render_widget(gauge, progress_chunks[0]); + + let time_label = Paragraph::new(time_text) + .style(Style::default().fg(PRIMARY_COLOR)) + .alignment(Alignment::Right); + f.render_widget(time_label, progress_chunks[1]); // Status let mode_text = if player.random_mode { "RANDOM" } else { "NORMAL" }; @@ -1114,6 +1216,10 @@ fn ui(f: &mut Frame, player: &Player) { Span::styled(" 0 ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(" - Reset speed (1.0x)"), ]), + Line::from(vec![ + Span::styled(" 1-9 ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), + Span::raw(" - Jump to 10%-90% of song"), + ]), Line::from(vec![ Span::styled(" q/Esc ", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)), Span::raw(" - Exit application"), @@ -1492,6 +1598,22 @@ fn main_loop(terminal: &mut Terminal>, player: &mut } } + KeyEvent { + code: KeyCode::Char(c @ '1'..='9'), + modifiers: KeyModifiers::NONE, + .. + } => { + if player.search_mode { + player.search_query.push(c); + let query = player.search_query.clone(); + player.fuzzy_search(&query); + } else { + // 1-9 = seek to 10%-90% of song + let pct = c.to_digit(10).unwrap(); + player.seek_to_percentage(pct); + } + } + KeyEvent { code: KeyCode::Char('?'), modifiers: KeyModifiers::NONE, @@ -1577,6 +1699,7 @@ fn main_loop(terminal: &mut Terminal>, player: &mut // Check if stretch completed in background player.check_stretch_result(); + player.check_next_chunk(); // Check if current song/chunk finished if player.is_playing && player.stretch_rx.is_none() { @@ -1586,18 +1709,37 @@ fn main_loop(terminal: &mut Terminal>, player: &mut drop(sink); let song_pos = player.current_song_position(); - let song_done = match player.song_duration { - Some(dur) => song_pos >= dur, - None => true, + let song_done = if let Some(dur) = player.song_duration { + song_pos >= dur + } else if let Some(ref raw) = player.decoded_samples { + // Fallback: check if we've consumed all samples + let samples_per_sec = + player.decoded_sample_rate as f64 * player.decoded_channels as f64; + let total_secs = raw.len() as f64 / samples_per_sec; + song_pos.as_secs_f64() >= total_secs + } else { + true }; if player.playing_stretched && !song_done { - // Stretched chunk ended but song continues — stretch next chunk - player.seek_offset = song_pos; - player.playback_start = None; - player.playing_stretched = false; - player.is_playing = false; - player.spawn_stretch(); + // Stretched chunk ended but song continues + player.seek_offset = player.chunk_end_pos; + + if let Some(next) = player.next_chunk.take() { + // Pre-fetched chunk ready — seamless swap, no gap + player.apply_stretched(next); + } else { + // Not ready yet — pause briefly and wait + player.playback_start = None; + player.playing_stretched = false; + player.is_playing = false; + if player.next_chunk_rx.is_some() { + // Already being computed, promote to main stretch_rx + player.stretch_rx = player.next_chunk_rx.take(); + } else { + player.spawn_stretch(); + } + } } else { // Song actually finished — play next player.is_playing = false;