Skip to content

Commit 28c45ab

Browse files
authored
Merge pull request #417 from Splode/bugfix/custom-audio-format-support
Bugfix/custom audio format support
2 parents 98d9ee5 + 3fe2a9b commit 28c45ab

7 files changed

Lines changed: 68 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
### Bug Fixes
44

5+
- **Custom notification sounds not playing on Windows** — ADPCM-encoded WAV files (the default output of Windows Sound Recorder) failed to decode because the `symphonia-codec-adpcm` crate was not in the dependency tree. The app would silently fall back to the built-in sound while still displaying the custom filename in Settings. ADPCM decoding is now enabled by adding `symphonia-codec-adpcm` as a direct dependency.
6+
- **Custom sound file picker offered FLAC, which could never be decoded** — the file picker filter included `.flac` as a valid extension, but FLAC decoding was never compiled in. FLAC has been removed from the filter.
7+
- **Unsupported audio format selected silently reverted to default** — when a custom sound file was copied successfully but failed to decode (unsupported encoding), the app fell back to the default sound with no indication to the user. The file is now probed immediately after being copied; if decoding fails the file is discarded and an inline error message is shown below the relevant audio row in Settings → Notifications.
58
- **Timer not restarting correctly after quickly starting the next round** — when a round completed and the user clicked Start before the engine's follow-up duration update arrived, the update (a `Reconfigure` command) would force the engine back to Idle, cancelling the freshly started timer. The follow-up is now sent as a lighter-weight `Prime` command that updates the stored duration in place without affecting the running phase. Contributed by [@SeanTong11](https://github.com/SeanTong11).
69
- **Timer completing instantly when a stale duration update arrives mid-round** — in a rare race, the engine could receive a `Prime` command carrying a duration shorter than the already-elapsed time (e.g. if the round duration was shortened in settings while a timer was running). Without a guard this caused the timer to complete on the very next tick. The `Prime` handler now clamps the new duration to at least one tick beyond the current elapsed position so the timer always advances at least once before completing.
710

src-tauri/Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ axum = { version = "0.8", features = ["ws"] }
3737
futures-util = "0.3"
3838

3939
rodio = { version = "0.22", default-features = false, features = ["playback", "wav", "mp3", "vorbis"] }
40+
symphonia-codec-adpcm = "0.5.5"
4041
tiny-skia = "0.12"
4142
notify = "8"
4243

src-tauri/src/audio/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ pub fn find_custom_files(audio_dir: &Path) -> CustomAudioPaths {
209209
}
210210
}
211211

212+
/// Probe `path` by attempting to construct a `rodio::Decoder`.
213+
/// Returns `Ok(())` if the format and codec are recognised, or an error
214+
/// string suitable for returning to the frontend.
215+
pub fn probe_audio_file(path: &Path) -> Result<(), String> {
216+
let reader = std::fs::File::open(path)
217+
.map(std::io::BufReader::new)
218+
.map_err(|e| format!("cannot open file: {e}"))?;
219+
Decoder::new(reader)
220+
.map(|_| ())
221+
.map_err(|e| format!("audio format not supported: {e}"))
222+
}
223+
212224
// ---------------------------------------------------------------------------
213225
// Audio thread
214226
// ---------------------------------------------------------------------------

src-tauri/src/commands.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,15 @@ pub fn audio_set_custom(
451451
let dest = audio_dir.join(format!("{stem}.{ext}"));
452452
std::fs::copy(src, &dest).map_err(|e| e.to_string())?;
453453

454+
// Verify the copied file is decodable before committing. If it fails,
455+
// clean up the orphan and sync in-memory state to default (the old file
456+
// was already deleted above).
457+
if let Err(e) = audio::probe_audio_file(&dest) {
458+
let _ = std::fs::remove_file(&dest);
459+
audio_state.clear_custom_path(&cue);
460+
return Err(e);
461+
}
462+
454463
audio_state.set_custom_path(&cue, dest);
455464

456465
let display_name = src

src/lib/components/settings/sections/NotificationsSection.svelte

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@
4343
else longBreakAlert = val;
4444
}
4545
46+
let workAlertError = $state<string | null>(null);
47+
let shortBreakAlertError = $state<string | null>(null);
48+
let longBreakAlertError = $state<string | null>(null);
49+
50+
function getError(id: CueKey): string | null {
51+
if (id === 'work_alert') return workAlertError;
52+
if (id === 'short_break_alert') return shortBreakAlertError;
53+
return longBreakAlertError;
54+
}
55+
56+
function setError(id: CueKey, val: string | null) {
57+
if (id === 'work_alert') workAlertError = val;
58+
else if (id === 'short_break_alert') shortBreakAlertError = val;
59+
else longBreakAlertError = val;
60+
}
61+
4662
async function refreshAudioInfo() {
4763
try {
4864
const info: CustomAudioInfo = await getCustomAudioInfo();
@@ -76,6 +92,7 @@
7692
}
7793
7894
async function pickAudio(id: CueKey) {
95+
setError(id, null);
7996
let path: string | null = null;
8097
try {
8198
path = await openAudioFilePicker();
@@ -87,15 +104,18 @@
87104
try {
88105
const displayName = await setCustomAudio(id, path);
89106
setFileName(id, displayName);
107+
setError(id, null);
90108
} catch (err) {
91109
await logError(`[audio] setCustomAudio failed: ${err}`);
110+
setError(id, String(err));
92111
}
93112
}
94113
95114
async function restoreAudio(id: CueKey) {
96115
try {
97116
await clearCustomAudio(id);
98117
setFileName(id, null);
118+
setError(id, null);
99119
} catch (err) {
100120
await logError(`[audio] clearCustomAudio failed: ${err}`);
101121
}
@@ -122,6 +142,9 @@
122142
<button class="btn-choose" onclick={() => pickAudio(id)}>{m.notif_btn_choose()}</button>
123143
</div>
124144
</div>
145+
{#if getError(id)}
146+
<p class="audio-error">{getError(id)}</p>
147+
{/if}
125148
{/each}
126149

127150
<div class="group-heading">{m.notif_group_desktop()}</div>
@@ -324,4 +347,12 @@
324347
background: color-mix(in oklch, var(--color-foreground) 14%, transparent);
325348
color: var(--color-foreground);
326349
}
350+
351+
.audio-error {
352+
margin: 0;
353+
padding: 2px 20px 8px;
354+
font-size: 0.72rem;
355+
color: var(--color-danger, #e05252);
356+
font-family: monospace;
357+
}
327358
</style>

src/lib/ipc/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const clearCustomAudio = (cue: string) => invoke<void>('audio_clear_custo
6060
export const openAudioFilePicker = (): Promise<string | null> =>
6161
dialogOpen({
6262
multiple: false,
63-
filters: [{ name: 'Audio', extensions: ['mp3', 'wav', 'ogg', 'flac'] }],
63+
filters: [{ name: 'Audio', extensions: ['mp3', 'wav', 'ogg'] }],
6464
}) as Promise<string | null>;
6565

6666
// --- Diagnostic log commands ---

0 commit comments

Comments
 (0)