Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.
transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
-> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
as immutable audit trail). Includes architecture-map references
to magnotia_core::*, magnotia_storage::*, etc. now pointing at
lumotia_*; dev-setup.md tracing output examples (lumotia_startup
target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
("lumotia_task_sync"); magnotia_locale i18n localStorage key
renamed + migration shim added; CSS keyframe names
magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
to "lumotia era" earlier — restored).
Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
legacy detection strings in legacy_and_target_paths() so the
migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
— migration call sites reference the legacy magnotia keys
deliberately.
- docs/handovers/ — historical audit trail.
cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
5.5 KiB
Markdown
87 lines
5.5 KiB
Markdown
---
|
|
name: Audio file decoding (symphonia)
|
|
type: architecture-map-page
|
|
slice: 03-audio-transcription
|
|
last_verified: 2026/05/09
|
|
---
|
|
|
|
# Audio file decoding (symphonia)
|
|
|
|
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio file decoding
|
|
|
|
**Plain English summary.** When the user imports an audio file, `decode_audio_file` reads it through `symphonia`, downmixes to mono, and returns f32 PCM at the file's native rate. `decode_and_resample` chains this with `resample_to_16khz` on a blocking task so the Tauri command stays async. A short `probe_audio_duration_secs` helper reads the duration without decoding the body, used for "is this longer than the import limit?" preflight.
|
|
|
|
## At a glance
|
|
|
|
- Crate: `lumotia-audio`
|
|
- Paths: `crates/audio/src/decode.rs` (283 LOC), `crates/audio/src/concurrency.rs` (19 LOC).
|
|
- External deps: `symphonia 0.5` with features `mp3, aac, flac, pcm, vorbis, wav, ogg, isomp4`.
|
|
- Internal callers (best effort, slice 2 reconciles): the import-file Tauri command and the file-mode transcription command call `decode_and_resample`. Standalone `decode_audio_file` is exposed for tests and any path that wants the native rate.
|
|
|
|
Public surface:
|
|
|
|
- `pub fn decode_audio_file(path: &Path) -> Result<AudioSamples>` — `crates/audio/src/decode.rs:23`.
|
|
- `pub fn decode_audio_file_limited(path: &Path, max_duration_secs: Option<f64>) -> Result<AudioSamples>` — `crates/audio/src/decode.rs:27`.
|
|
- `pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>>` — `crates/audio/src/decode.rs:43`.
|
|
- `pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples>` — `crates/audio/src/concurrency.rs:11`.
|
|
|
|
## What's in here
|
|
|
|
### `decode_audio_file` and friends
|
|
|
|
`decode_audio_file` (`decode.rs:23`) delegates to `decode_audio_file_limited(path, None)`. The `_limited` variant accepts a hard cap in seconds; once the per-track sample count crosses `(max_duration_secs * sample_rate).ceil()` it returns `MagnotiaError::AudioDecodeFailed("Audio is longer than the {:.0} minute import limit", ...)`. Used by the Tauri import command to enforce the configured maximum.
|
|
|
|
Both call into `decode_media_stream` (`decode.rs:77`), which is the actual decode loop and the seam tests inject a `MediaSource` into. Steps:
|
|
|
|
1. Probe the format (`symphonia::default::get_probe()`).
|
|
2. Pick the default track; reject if there is none or the codec sample rate is `0`.
|
|
3. Build a decoder via `symphonia::default::get_codecs()`.
|
|
4. Loop:
|
|
- `format.next_packet()` — `UnexpectedEof` is the natural EOF marker; any other error is propagated as `AudioDecodeFailed`. `ResetRequired` is treated as a discontinuity error.
|
|
- Skip packets that don't belong to the chosen track.
|
|
- Decode into a `SampleBuffer<f32>`.
|
|
- Average channels into mono if `channels > 1`; otherwise extend the output verbatim.
|
|
- Apply the duration cap if one was provided.
|
|
5. Reject empty output (`No audio data decoded`) and return `AudioSamples::new(samples, sample_rate, 1)`.
|
|
|
|
### `probe_audio_duration_secs`
|
|
|
|
`decode.rs:43`. Re-runs the probe step only, reads `track.codec_params.n_frames` and `sample_rate`, returns `Some(seconds)` if both are available. Cheaper than a full decode for "is this file too long?" gating.
|
|
|
|
### `decode_and_resample`
|
|
|
|
`concurrency.rs:11`. Wraps `decode_audio_file` + `resample_to_16khz` in `tokio::task::spawn_blocking`. Used by every async caller that needs ready-to-transcribe samples. Maps `JoinError` to `MagnotiaError::AudioDecodeFailed("Task join error: {e}")`.
|
|
|
|
## Data flow
|
|
|
|
```
|
|
Path
|
|
└─ File::open
|
|
└─ MediaSourceStream
|
|
└─ Hint(extension)
|
|
└─ probe → format + codec_params
|
|
└─ Decoder
|
|
└─ next_packet loop
|
|
├─ packet → decoded → SampleBuffer<f32>
|
|
└─ if channels > 1: mean across channels (mono mixdown)
|
|
→ samples (f32, native rate)
|
|
```
|
|
|
|
`decode_and_resample` chains the above with `resample_to_16khz` so the caller gets `AudioSamples` already at 16 kHz.
|
|
|
|
## Watch-outs
|
|
|
|
- **RB-09 regression (2026-04-22 review).** The previous loop did `Err(_) => break` and silently returned partial audio on a mid-stream I/O error. Current code distinguishes EOF, `ResetRequired`, and other errors. Test at `decode.rs:262` injects a flaky `MediaSource` to keep this honest.
|
|
- **AAC and ISO MP4 are pulled in unconditionally** by the symphonia feature set. Patent-licence implications for AAC are flagged in the brief; this is not a per-build decision today.
|
|
- **Channels are averaged, not mixed psychoacoustically.** Stereo to mono goes via mean of all channels per sample. Fine for ASR; lossy for any future task that needs spatial cues.
|
|
- **Whole-file decode loads everything into memory.** The duration cap is the only guard. Long imports exceed RAM long before they exceed disk.
|
|
- **`Hint` only carries extension.** A `.mp3` named `.bin` will fail to probe even if the bytes are valid MP3. Caller responsibility to keep extensions truthful.
|
|
- **`decode_and_resample` returns the resampled output.** If a downstream caller wanted the original rate (for a non-ASR purpose), it must call `decode_audio_file` directly.
|
|
|
|
## See also
|
|
|
|
- [Audio resampling](audio-resampling.md) — `resample_to_16khz` is the second half of `decode_and_resample`.
|
|
- [Audio WAV I/O](audio-wav-io.md) — `read_wav` is the WAV-only fast path that uses `hound` instead of symphonia.
|
|
- `lumotia_core::types::AudioSamples` (slice 5) — the value type returned.
|
|
- Tests at `decode.rs:175` (RB-09 regression among others).
|