diff --git a/README.md b/README.md index cba2524..0db0d49 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,36 +44,30 @@ 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, `0` to reset +6. **Jump**: Use `gg` (first song) or `G` (last song) +7. **Help**: Press `?` 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 -> **Tip**: Press **x** anytime to view the interactive controls popup! +> **Tip**: Press **?** anytime to view the interactive controls popup! ### Essential Keys @@ -80,7 +75,7 @@ ln -s /path/to/your/music ./data |-----|--------| | **`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 @@ -93,6 +88,10 @@ ln -s /path/to/your/music ./data | `gg` / `G` | Jump to first/last song | | `,` / `.` | Seek backward/forward 5 seconds | | `<` / `>` | Same as above | +| `+` 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 @@ -127,23 +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 │ -│ 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 @@ -158,6 +161,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 `0` 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 +201,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,8 +209,7 @@ MUSIX features a clean, 4-panel interface that maximizes space for your music: ``` musix/ ├── src/ -│ └── main.rs # Complete application (~700 lines) -├── data/ # MP3 files (optional) +│ └── main.rs # Complete application (~1600 lines) ├── .github/workflows/ # CI/CD automation ├── Cargo.toml # Dependencies and metadata ├── rustfmt.toml # Code formatting rules @@ -230,33 +237,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/ +# Specify a directory containing MP3 files +musix /path/to/your/music -# Option 2: Create symbolic link -ln -s /path/to/your/music ./data - -# 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 diff --git a/src/main.rs b/src/main.rs index 57eb1a2..bdb7c41 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}, }; @@ -20,6 +21,170 @@ use ratatui::{ }; use rodio::{Decoder, OutputStream, Sink, Source}; +/// 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 { + return samples.to_vec(); + } + + let total_frames = samples.len() / ch; + 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 == 0 || total_frames < segment_frames { + return samples.to_vec(); + } + + 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; + + // 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; + } + + 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; + } + } + + let denom = (energy_a * energy_b).sqrt(); + let corr = if denom > 1e-10 { dot / denom } else { 0.0 }; + + 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 { + 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; + } + } + + // 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[(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(out_len * ch); + output +} + +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 { name: String, @@ -48,6 +213,17 @@ 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, + 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 { @@ -64,8 +240,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 +274,7 @@ impl Player { }; let filtered_songs: Vec = (0..songs.len()).collect(); - + let player = Player { songs, current_index: 0, @@ -118,6 +294,16 @@ 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, + next_chunk: None, + next_chunk_rx: None, + chunk_end_pos: Duration::from_secs(0), }; // Set initial terminal title @@ -139,37 +325,57 @@ 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(); + // 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; + + 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); + + 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.or(computed_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 +384,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 +477,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 +489,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 +504,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 +519,346 @@ 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 + 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 { + current_position.saturating_sub(seek_duration) + } else { + current_position + seek_duration + }; + + self.seek_to_position(new_position); + } + + 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 + } + + /// Stretch a 60s chunk starting from `self.seek_offset`, store receiver in `stretch_rx`. + fn spawn_stretch(&mut self) { + self.stretch_rx = self.spawn_stretch_at(self.seek_offset); + self.pending_stretched = None; + self.next_chunk = None; + self.next_chunk_rx = None; + } + + /// 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; + + 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)); + } + }); + + Some(rx) + } + + /// 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) { + 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; + } + if stretched.is_empty() { + // Stretch failed — resume original audio at current position + self.resume_original_audio(); } else { - Duration::from_secs(0) + self.apply_stretched(stretched); } - } else { - // Seek forward - current_position + seek_duration - }; + } + Err(mpsc::TryRecvError::Disconnected) => { + self.stretch_rx = None; + // Thread died — resume original audio + self.resume_original_audio(); + } + Err(mpsc::TryRecvError::Empty) => {} + } + } + } - // 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()); + /// 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; } - 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() { + 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) { + 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; + } + } + } + + /// 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 { + 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; + + 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; + 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) { 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 +869,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 +921,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 +943,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 +959,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 +973,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 +1037,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); @@ -613,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 } @@ -625,39 +1087,59 @@ 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" }; - 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 +1147,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 +1204,29 @@ 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(" 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"), ]), 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 +1260,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 +1272,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 +1338,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 +1353,7 @@ fn main_loop(terminal: &mut Terminal>, player: &mut break; } } - + KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, @@ -865,7 +1364,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 +1381,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 +1554,72 @@ 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('x'); + 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('0'); + let query = player.search_query.clone(); + player.fuzzy_search(&query); + } else { + player.reset_playback_rate(); + } + } + + 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, + .. + } => { + if !player.search_mode { player.show_controls_popup = !player.show_controls_popup; } } @@ -1139,16 +1697,57 @@ 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(); + player.check_next_chunk(); + + // 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 = 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 + 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; + player.playback_start = None; + player.seek_offset = Duration::from_secs(0); + player.playing_stretched = false; + player.next_song()?; + } } } } @@ -1158,7 +1757,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); }