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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src-tauri/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ src-tauri/
│ ├── state.rs # AppState, ServiceAccess trait
│ ├── db.rs # SQLite operations, migrations
│ ├── library.rs # High-level library API
│ ├── decoder.rs # Symphonia-based audio slice decoder (mono f32)
│ ├── scanner/ # Incremental file scanning (NEW)
│ │ ├── scan.rs # Single-pass streaming scanner
│ │ ├── hasher.rs # xxhash3 content hashing
Expand Down Expand Up @@ -103,6 +104,8 @@ trait ServiceAccess {
- `artists_fts` — indexed `artist_name` (with `artist_id` as UNINDEXED key)
- Backfilled from existing normalized `*_lower` columns.

**Migration v17:** Added `spectrogram_visible` BOOLEAN column to `config_data` (default `1`) for persisting the V2 lyrics editor's per-line spectrogram show/hide toggle across app launches.

**Indexes:** All `*_lower` columns + `content_hash`, `scan_status`, `modified_time+file_size` (fingerprint) + lyrics-presence indexes + LRCLIB composite index (`lrclib_instance`, `lrclib_id`)

### File Scanning (`scanner/`)
Expand Down Expand Up @@ -316,6 +319,8 @@ Search across all three entity types uses SQLite FTS5 (via `tracks_fts`, `albums
### Playback & Config
- `play_track(track_id?, file_path?, title?, album_name?, artist_name?, album_artist_name?, duration?)` - Unified playback for both library tracks (via `track_id`) and file-based tracks (via `file_path` with metadata)
- `pause/resume_track()`, `seek_track()`, `stop_track()`, `set_volume()` (persists volume to config), `set_playback_speed()`
- `get_audio_slice(file_path, start_ms, end_ms)` - Returns `{samples: Vec<f32>, sampleRate: u32}` (camelCase). Decodes a mono-downmixed slice.
- `set_spectrogram_visible(visible)`
- `get/set_directories()`, `get/set_config()`, `get_init()`
- Volume is loaded from config on startup and auto-saved when changed via `set_volume()`
- `open_devtools()`, `drain_notifications()`
Expand Down Expand Up @@ -360,6 +365,21 @@ struct MatchingTrack {

**Note:** The frontend currently uses a simpler approach with `get_tracks` + client-side filtering in `AssociateTrackModal.vue`.

## Audio Slice Decoding (`decoder.rs`)

Standalone Symphonia-based decoder that extracts mono f32 samples for a `[start_ms, end_ms)` window of any audio file the codebase already supports (via Symphonia's `all` features). Used by the V2 editor spectrogram.

**Process:**
1. Open file, probe format (extension hint), get default track
2. Seek to `start_ms` using `SeekMode::Accurate` (best-effort; some codecs may land at the nearest packet boundary)
3. Decode packets; for each packet, downmix all channels via average to mono and convert to f32 via Symphonia's `SampleBuffer<f32>` (auto-handles source sample formats)
4. Skip samples before `start_ms` and stop once `end_ms` is reached, using `packet.ts` to align
5. Return `AudioSlice { samples: Vec<f32>, sample_rate: u32 }`

**Errors:** `DecoderError` enum covers file open, Symphonia errors, missing-default-track, missing-sample-rate, invalid range.

**Performance:** Decoding a 5-second slice typically runs in ~30-50ms. The command wraps the decode in `tokio::spawn_blocking` to keep the async executor responsive.

## Audio Metadata Extraction (`get_audio_metadata`)

Extracts metadata from an audio file path using the existing scanner logic. Used by the file picker in the track association flow.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE config_data ADD spectrogram_visible BOOLEAN DEFAULT 1;
10 changes: 9 additions & 1 deletion src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ pub fn get_config(db: &Connection) -> Result<PersistentConfig> {
try_embed_lyrics,
theme_mode,
lrclib_instance,
volume
volume,
spectrogram_visible
FROM config_data
LIMIT 1
"})?;
Expand All @@ -175,6 +176,7 @@ pub fn get_config(db: &Connection) -> Result<PersistentConfig> {
theme_mode: r.get("theme_mode")?,
lrclib_instance: r.get("lrclib_instance")?,
volume: r.get("volume")?,
spectrogram_visible: r.get("spectrogram_visible")?,
})
})?;
Ok(row)
Expand Down Expand Up @@ -220,6 +222,12 @@ pub fn set_volume_config(volume: f64, db: &Connection) -> Result<()> {
Ok(())
}

pub fn set_spectrogram_visible_config(visible: bool, db: &Connection) -> Result<()> {
let mut statement = db.prepare("UPDATE config_data SET spectrogram_visible = ? WHERE 1")?;
statement.execute([visible])?;
Ok(())
}

pub fn find_artist(name: &str, db: &Connection) -> Result<i64> {
let mut statement = db.prepare("SELECT id FROM artists WHERE name = ?")?;
let id: i64 = statement.query_row([name], |r| r.get(0))?;
Expand Down
162 changes: 162 additions & 0 deletions src-tauri/src/decoder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
use std::fs::File;
use std::path::Path;
use symphonia::core::audio::{SampleBuffer, SignalSpec};
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DecoderError {
#[error("Failed to open file: {0}")]
OpenFailed(#[from] std::io::Error),

#[error("Symphonia error: {0}")]
Symphonia(#[from] SymphoniaError),

#[error("No default audio track found in file")]
NoDefaultTrack,

#[error("Audio track is missing sample rate metadata")]
MissingSampleRate,

#[error("Invalid time range: start_ms={start_ms}, end_ms={end_ms}")]
InvalidRange { start_ms: i64, end_ms: i64 },
}

pub struct AudioSlice {
pub samples: Vec<f32>,
pub sample_rate: u32,
}

/// Decode the audio file at `path` between `[start_ms, end_ms)` and return
/// mono f32 samples normalized to roughly [-1.0, 1.0]. Multi-channel sources
/// are downmixed by averaging.
pub fn decode_slice(path: &Path, start_ms: i64, end_ms: i64) -> Result<AudioSlice, DecoderError> {
if end_ms <= start_ms || start_ms < 0 {
return Err(DecoderError::InvalidRange { start_ms, end_ms });
}

let file = File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());

let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
hint.with_extension(ext);
}

let probed = symphonia::default::get_probe().format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)?;
let mut format = probed.format;

let track = format
.default_track()
.ok_or(DecoderError::NoDefaultTrack)?;
let track_id = track.id;

let params = &track.codec_params;
let sample_rate = params.sample_rate.ok_or(DecoderError::MissingSampleRate)?;
let n_channels = params.channels.map(|c| c.count()).unwrap_or(1).max(1);

let mut decoder = symphonia::default::get_codecs().make(params, &DecoderOptions::default())?;

let start_seconds = start_ms as f64 / 1000.0;
let _ = format.seek(
SeekMode::Accurate,
SeekTo::Time {
time: Time::from(start_seconds),
track_id: Some(track_id),
},
)?;

let start_sample_pos = (start_seconds * sample_rate as f64).round() as u64;
let end_sample_pos = ((end_ms as f64 / 1000.0) * sample_rate as f64).round() as u64;
let target_samples = end_sample_pos.saturating_sub(start_sample_pos) as usize;

let mut samples: Vec<f32> = Vec::with_capacity(target_samples);

loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => return Err(DecoderError::Symphonia(e)),
};

if packet.track_id() != track_id {
continue;
}

let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => return Err(DecoderError::Symphonia(e)),
};

let spec: SignalSpec = *decoded.spec();
let frames = decoded.frames();
if frames == 0 {
continue;
}

let packet_start = packet.ts;
let packet_end = packet_start + frames as u64;

if packet_end <= start_sample_pos {
continue;
}
if packet_start >= end_sample_pos {
break;
}

let skip_frames = if packet_start < start_sample_pos {
(start_sample_pos - packet_start) as usize
} else {
0
};

let usable_frames_after_skip = frames.saturating_sub(skip_frames);
let remaining_needed = target_samples.saturating_sub(samples.len());
let frames_to_take = usable_frames_after_skip.min(remaining_needed);

if frames_to_take == 0 {
if samples.len() >= target_samples {
break;
}
continue;
}

let mut sample_buf = SampleBuffer::<f32>::new(frames as u64, spec);
sample_buf.copy_interleaved_ref(decoded);
let interleaved = sample_buf.samples();

let channels = n_channels;
let inv_channels = 1.0_f32 / channels as f32;
for frame_idx in skip_frames..(skip_frames + frames_to_take) {
let base = frame_idx * channels;
let mut sum = 0.0_f32;
for ch in 0..channels {
sum += interleaved[base + ch];
}
samples.push(sum * inv_channels);
}

if samples.len() >= target_samples {
break;
}
}

Ok(AudioSlice {
samples,
sample_rate,
})
}
36 changes: 36 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
)]

pub mod db;
pub mod decoder;
pub mod export;
pub mod library;
pub mod lrclib;
Expand Down Expand Up @@ -1136,6 +1137,31 @@ async fn flag_lyrics(
Ok(())
}

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AudioSliceResponse {
samples: Vec<f32>,
sample_rate: u32,
}

#[tauri::command]
async fn get_audio_slice(
file_path: String,
start_ms: i64,
end_ms: i64,
) -> Result<AudioSliceResponse, String> {
let path = std::path::PathBuf::from(file_path);
let slice = tokio::task::spawn_blocking(move || decoder::decode_slice(&path, start_ms, end_ms))
.await
.map_err(|e| format!("Audio slice decode task failed: {}", e))?
.map_err(|e| e.to_string())?;

Ok(AudioSliceResponse {
samples: slice.samples,
sample_rate: slice.sample_rate,
})
}

#[tauri::command]
async fn play_track(
track_id: Option<i64>,
Expand Down Expand Up @@ -1420,6 +1446,14 @@ fn stop_track(app_state: tauri::State<AppState>) -> Result<(), String> {
Ok(())
}

#[tauri::command]
fn set_spectrogram_visible(visible: bool, app_handle: AppHandle) -> Result<(), String> {
app_handle
.db(|db| db::set_spectrogram_visible_config(visible, db))
.map_err(|err| err.to_string())?;
Ok(())
}

#[tauri::command]
fn set_volume(
volume: f64,
Expand Down Expand Up @@ -1588,8 +1622,10 @@ async fn main() {
resume_track,
seek_track,
stop_track,
get_audio_slice,
set_volume,
set_playback_speed,
set_spectrogram_visible,
open_devtools,
drain_notifications,
find_matching_tracks,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/persistent_entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,5 @@ pub struct PersistentConfig {
pub theme_mode: String,
pub lrclib_instance: String,
pub volume: f64,
pub spectrogram_visible: bool,
}
13 changes: 11 additions & 2 deletions src/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Module-level ref composables (singletons by design):

| Composable | Purpose |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useGlobalState()` | `isHotkey`, `themeMode`, `lrclibInstance` |
| `useGlobalState()` | `isHotkey`, `themeMode`, `lrclibInstance`, `spectrogramVisible` (+ `toggleSpectrogramVisible` action that persists to config via `set_spectrogram_visible`) |
| `usePlayer()` | `playingTrack`, `status`, `duration`, `progress`, `volume`. Supports both library tracks (with `id`) and file-based tracks (with `file_path`). Listens to `player-state` events |
| `useDownloader()` | Download queue, progress. Loop started by App.vue at boot |
| `useExporter()` | Mass export queue, progress. Used by ExportViewer modal |
Expand Down Expand Up @@ -103,9 +103,18 @@ Module-level ref composables (singletons by design):
- Word timing: multi-separator selection (Ctrl/Cmd+click + Shift+click), merge separators (`Delete`/`Backspace`), hover split preview snapped to grapheme boundaries, double-click split at cursor, and `Z` syncs selected separator then advances (last-word sync advances to next line)
- Narrow segment hint: when a segment is too narrow to show text, a visible hint is rendered beneath it with the next word text
- Boundary sync: can cascade adjacent boundaries so sync is not blocked by intervening separators, while staying within line bounds
- Word boundary chain enforcement: dragging the start of word N (or `Z`-syncing it) also writes `end_ms` on word N-1 in the same update, so the shared boundary stays consistent in the persisted data — not just visually. The dragged word's own `end_ms` is deliberately left alone, so an overshoot that produces `start_ms > end_ms` on a word is observable rather than silently clamped
- Word start-after-end warning: `SyncedWordTimingSegment.vue` renders a small amber `mdi/alert` above any segment whose `word.start_ms > word.end_ms`, with a `word start is after end` title tooltip. Triggers reactively during drag because `displayedWords` propagates the live `start_ms` while `word.end_ms` stays at its stored value
- Reset behavior: clears persisted word timings and reloads default (non-persisted) segmentation
- Line-start sync behavior: syncing line start shifts existing word boundaries by the same offset
- Line-start mutations (nudge `±100ms`, sync to progress, bulk shift): `setLineStartMs` locks `word[0].start_ms` to the new line start, then forward-cascades successors so each word starts at least `MIN_WORD_DURATION_MS` (10 ms) after its predecessor.
- Line-end mutations (nudge `±100ms`, sync to progress, sync-to-next): `setLineEndMs` mirrors the start logic for the last word only — if `word[lastIdx].end_ms` is explicit (finite), it's locked to the new line end. Implicit ends (the common case, derived by the lane from the next word's `start_ms` or `laneEndMs`) are left untouched.
- Selection behavior: selecting a synced line starts at the second boundary by default
- Synced line operations: each row has a `sync-end-to-next` button (`arrow-collapse-right` icon) that snaps the current line's `end_ms` to the next line's `start_ms`. `addSyncedLineAt` pre-fills `start_ms` from the previous line's `end_ms` and `end_ms` from the next line's `start_ms`, so inserts drop in pre-timed
- Overlap highlight: `SyncedLyricsEditor.vue` computes `overlappingLineIndexes` — every line that's part of an overlapping pair anywhere in the song gets an amber tint, computed in a single-pass sweep that relies on the sorted-by-`start_ms` invariant
- Stable line identity & reorder: each in-memory synced line carries an integer `id` (generated by `nextLineId()` in `utils/lyricsfile.js`, attached by `normalizeSyncedLine`, stripped before YAML output). The v-for keys by `line.id`, so a `start_ms` mutation that crosses a neighbor re-sorts the array while preserving inline-edit state on the moved row. Selection follows the moved line by id; saves re-attach existing ids by array index so v-for keys don't churn across round-trips
- Spectrogram: when audio is available and the toggle is on, `SpectrogramPanel.vue` renders an inferno-mapped log-frequency spectrogram (50 Hz–8 kHz) of the selected line's audio slice above the timeline. Backed by Rust `get_audio_slice` + frontend FFT. Results memoized by `(file_path, start_ms, end_ms)` in a bounded module-level cache. The lane grows from `h-[5rem]` to `h-[13rem]` when the spectrogram is shown. Visibility is toggled by a circular waveform/eye-off button in the lane header and persisted globally via `useGlobalState().spectrogramVisible` and `config_data.spectrogram_visible`
- Click-to-seek: clicking on the spectrogram or the word-timing timeline emits `seek` and jumps playback to that horizontal position. Clicks are NOT consumed by boundary handles or by word segments whose split preview is currently being rendered.
- Playback recovery: clicking play on a line after the track has finished now reloads the track with full metadata before seeking, instead of racing a bare-`{trackId}` `play_track` invocation against `seek_track`

### Keyboard Shortcuts Details

Expand Down
3 changes: 2 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { useToast } from 'vue-toastification'

const appWindow = getCurrentWebviewWindow()
const toast = useToast()
const { themeMode, setThemeMode, setLrclibInstance } = useGlobalState()
const { themeMode, setThemeMode, setLrclibInstance, setSpectrogramVisible } = useGlobalState()
const { downloadNext } = useDownloader()
const { exportNext } = useExporter()
const { setVolume } = usePlayer()
Expand Down Expand Up @@ -97,6 +97,7 @@ const loadGlobalState = async () => {
const config = await invoke('get_config')
setThemeMode(config.theme_mode)
setLrclibInstance(config.lrclib_instance)
setSpectrogramVisible(config.spectrogram_visible ?? true)
// Set initial volume from config (default to 1.0 if not set)
const volume = config.volume !== undefined ? config.volume : 1.0
setVolume(volume)
Expand Down
Loading