Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import FileViewer from "./components/FileViewer";
import UpdateNotifier from "./components/UpdateNotifier";
import WelcomeScreen from "./components/WelcomeScreen";
import { useFileSession } from "./hooks/useFileSession";
import type { FileAction } from "./types";
import "./electron.d.ts";

type AppState = "welcome" | "viewing" | "completed";
Expand Down Expand Up @@ -38,7 +39,7 @@ function App() {
}
};

const handleFileAction = async (action: "delete" | "keep") => {
const handleFileAction = async (action: FileAction) => {
if (action === "delete") {
await deleteFile();
} else {
Expand Down
128 changes: 15 additions & 113 deletions src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { ArrowLeft, CheckCircle, Eye, RotateCcw, Settings, XCircle } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { type AppSettings, formatDate, formatFileSize, type SessionState } from "../types";
import { playActionSound } from "../sound";
import {
type AppSettings,
type FileAction,
formatDate,
formatFileSize,
type SessionState,
} from "../types";
import FilePreview from "./FilePreview";
import SettingsComponent from "./Settings";

interface FileViewerProps {
sessionState: SessionState;
onFileAction: (action: "delete" | "keep") => Promise<void>;
onFileAction: (action: FileAction) => Promise<void>;
onUndo: () => void;
onBack: () => void;
}
Expand All @@ -25,7 +32,7 @@ const FileViewer: React.FC<FileViewerProps> = ({ sessionState, onFileAction, onU
});
const [actionFeedback, setActionFeedback] = useState<{
show: boolean;
type: "delete" | "keep";
type: FileAction;
}>({ show: false, type: "delete" });

// Load settings on component mount
Expand All @@ -50,115 +57,8 @@ const FileViewer: React.FC<FileViewerProps> = ({ sessionState, onFileAction, onU
}
}, []);

const playActionSound = useCallback(
(action: "delete" | "keep") => {
if (!settings.soundEffects) return;

// Create audio context for sound feedback
try {
const audioContext = new (
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
)();

if (action === "delete") {
// Delete sound: Descending "whoosh" effect (like something being thrown away)
const oscillator1 = audioContext.createOscillator();
const oscillator2 = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const filter = audioContext.createBiquadFilter();

// Connect: oscillators -> filter -> gain -> destination
oscillator1.connect(filter);
oscillator2.connect(filter);
filter.connect(gainNode);
gainNode.connect(audioContext.destination);

// Two oscillators for richer sound
oscillator1.frequency.setValueAtTime(300, audioContext.currentTime);
oscillator1.frequency.exponentialRampToValueAtTime(50, audioContext.currentTime + 0.3);
oscillator1.type = "sawtooth";

oscillator2.frequency.setValueAtTime(200, audioContext.currentTime);
oscillator2.frequency.exponentialRampToValueAtTime(30, audioContext.currentTime + 0.3);
oscillator2.type = "triangle";

// Low-pass filter for "whoosh" effect
filter.type = "lowpass";
filter.frequency.setValueAtTime(800, audioContext.currentTime);
filter.frequency.exponentialRampToValueAtTime(200, audioContext.currentTime + 0.3);

// Volume envelope
gainNode.gain.setValueAtTime(0.15, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);

oscillator1.start(audioContext.currentTime);
oscillator1.stop(audioContext.currentTime + 0.3);
oscillator2.start(audioContext.currentTime);
oscillator2.stop(audioContext.currentTime + 0.3);
} else {
// Keep sound: Ascending "chime" effect (like a positive confirmation)
const oscillator1 = audioContext.createOscillator();
const oscillator2 = audioContext.createOscillator();
const oscillator3 = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const filter = audioContext.createBiquadFilter();

// Connect: oscillators -> filter -> gain -> destination
oscillator1.connect(filter);
oscillator2.connect(filter);
oscillator3.connect(filter);
filter.connect(gainNode);
gainNode.connect(audioContext.destination);

// Three oscillators for a pleasant chord
oscillator1.frequency.setValueAtTime(523.25, audioContext.currentTime); // C5
oscillator1.frequency.exponentialRampToValueAtTime(
659.25,
audioContext.currentTime + 0.2
); // E5
oscillator1.type = "sine";

oscillator2.frequency.setValueAtTime(659.25, audioContext.currentTime); // E5
oscillator2.frequency.exponentialRampToValueAtTime(
783.99,
audioContext.currentTime + 0.2
); // G5
oscillator2.type = "sine";

oscillator3.frequency.setValueAtTime(783.99, audioContext.currentTime); // G5
oscillator3.frequency.exponentialRampToValueAtTime(
1046.5,
audioContext.currentTime + 0.2
); // C6
oscillator3.type = "sine";

// High-pass filter for bright, clear sound
filter.type = "highpass";
filter.frequency.setValueAtTime(400, audioContext.currentTime);

// Volume envelope with quick attack and decay
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(0.12, audioContext.currentTime + 0.05);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.25);

oscillator1.start(audioContext.currentTime);
oscillator1.stop(audioContext.currentTime + 0.25);
oscillator2.start(audioContext.currentTime);
oscillator2.stop(audioContext.currentTime + 0.25);
oscillator3.start(audioContext.currentTime);
oscillator3.stop(audioContext.currentTime + 0.25);
}
} catch (_error) {
// Silently fail if audio context is not available
console.log("Audio feedback not available");
}
},
[settings.soundEffects]
);

const performAction = useCallback(
async (action: "delete" | "keep") => {
async (action: FileAction) => {
// Check if we need confirmation for delete
if (action === "delete" && settings.confirmDelete) {
const confirmed = window.confirm(`Are you sure you want to delete "${currentFile.name}"?`);
Expand All @@ -169,7 +69,9 @@ const FileViewer: React.FC<FileViewerProps> = ({ sessionState, onFileAction, onU
setActionFeedback({ show: true, type: action });

// Play audio feedback
playActionSound(action);
if (settings.soundEffects) {
playActionSound(action);
}

// Trigger action after brief delay for feedback
setTimeout(async () => {
Expand All @@ -184,7 +86,7 @@ const FileViewer: React.FC<FileViewerProps> = ({ sessionState, onFileAction, onU
setActionFeedback({ show: false, type: action });
}, 200);
},
[onFileAction, playActionSound, settings.confirmDelete, currentFile.name]
[onFileAction, settings.confirmDelete, settings.soundEffects, currentFile.name]
);

// Keyboard shortcuts
Expand Down
78 changes: 78 additions & 0 deletions src/sound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// A fake AudioContext: enough of the graph (oscillator/gain/filter nodes,
// their scheduling methods) for sound.ts to run without throwing, so tests
// can assert on construction rather than on exact frequencies/envelopes.
function mockAudioContext() {
const node = () => ({
connect: vi.fn(),
frequency: { setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
gain: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
},
type: "",
start: vi.fn(),
stop: vi.fn(),
});

return vi.fn(() => ({
currentTime: 0,
destination: {},
createOscillator: vi.fn(node),
createGain: vi.fn(node),
createBiquadFilter: vi.fn(node),
}));
}

describe("playActionSound", () => {
beforeEach(() => {
// sound.ts caches its AudioContext in module state, so each test needs a
// fresh module instance to observe construction in isolation.
vi.resetModules();
});

afterEach(() => {
// @ts-expect-error test-only cleanup of a global this suite sets up
window.AudioContext = undefined;
});

it("does not throw when AudioContext is unavailable", async () => {
const { playActionSound } = await import("./sound");

expect(() => playActionSound("delete")).not.toThrow();
expect(() => playActionSound("keep")).not.toThrow();
});

it("constructs an AudioContext to play a delete sound", async () => {
const ctor = mockAudioContext();
window.AudioContext = ctor as unknown as typeof AudioContext;
const { playActionSound } = await import("./sound");

playActionSound("delete");

expect(ctor).toHaveBeenCalledOnce();
});

it("constructs an AudioContext to play a keep sound", async () => {
const ctor = mockAudioContext();
window.AudioContext = ctor as unknown as typeof AudioContext;
const { playActionSound } = await import("./sound");

playActionSound("keep");

expect(ctor).toHaveBeenCalledOnce();
});

it("reuses the same AudioContext across multiple calls", async () => {
const ctor = mockAudioContext();
window.AudioContext = ctor as unknown as typeof AudioContext;
const { playActionSound } = await import("./sound");

playActionSound("delete");
playActionSound("keep");

expect(ctor).toHaveBeenCalledOnce();
});
});
125 changes: 125 additions & 0 deletions src/sound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { FileAction } from "./types";

// Lazily created and reused across calls instead of a fresh AudioContext
// per call. undefined = not yet attempted, null = attempted and
// unavailable (a deterministic environment-support gap, not something
// that resolves mid-session, so we don't retry construction on every call).
let audioContext: AudioContext | null | undefined;

function getAudioContext(): AudioContext | null {
if (audioContext !== undefined) return audioContext;

try {
const Ctor =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
audioContext = new Ctor();
} catch (_error) {
// Silently fail if audio context is not available
console.log("Audio feedback not available");
audioContext = null;
}

return audioContext;
}

// Delete sound: Descending "whoosh" effect (like something being thrown away)
function playDeleteSound(ctx: AudioContext) {
const oscillator1 = ctx.createOscillator();
const oscillator2 = ctx.createOscillator();
const gainNode = ctx.createGain();
const filter = ctx.createBiquadFilter();

// Connect: oscillators -> filter -> gain -> destination
oscillator1.connect(filter);
oscillator2.connect(filter);
filter.connect(gainNode);
gainNode.connect(ctx.destination);

// Two oscillators for richer sound
oscillator1.frequency.setValueAtTime(300, ctx.currentTime);
oscillator1.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.3);
oscillator1.type = "sawtooth";

oscillator2.frequency.setValueAtTime(200, ctx.currentTime);
oscillator2.frequency.exponentialRampToValueAtTime(30, ctx.currentTime + 0.3);
oscillator2.type = "triangle";

// Low-pass filter for "whoosh" effect
filter.type = "lowpass";
filter.frequency.setValueAtTime(800, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.3);

// Volume envelope
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);

oscillator1.start(ctx.currentTime);
oscillator1.stop(ctx.currentTime + 0.3);
oscillator2.start(ctx.currentTime);
oscillator2.stop(ctx.currentTime + 0.3);
}

// Keep sound: Ascending "chime" effect (like a positive confirmation)
function playKeepSound(ctx: AudioContext) {
const oscillator1 = ctx.createOscillator();
const oscillator2 = ctx.createOscillator();
const oscillator3 = ctx.createOscillator();
const gainNode = ctx.createGain();
const filter = ctx.createBiquadFilter();

// Connect: oscillators -> filter -> gain -> destination
oscillator1.connect(filter);
oscillator2.connect(filter);
oscillator3.connect(filter);
filter.connect(gainNode);
gainNode.connect(ctx.destination);

// Three oscillators for a pleasant chord
oscillator1.frequency.setValueAtTime(523.25, ctx.currentTime); // C5
oscillator1.frequency.exponentialRampToValueAtTime(659.25, ctx.currentTime + 0.2); // E5
oscillator1.type = "sine";

oscillator2.frequency.setValueAtTime(659.25, ctx.currentTime); // E5
oscillator2.frequency.exponentialRampToValueAtTime(783.99, ctx.currentTime + 0.2); // G5
oscillator2.type = "sine";

oscillator3.frequency.setValueAtTime(783.99, ctx.currentTime); // G5
oscillator3.frequency.exponentialRampToValueAtTime(1046.5, ctx.currentTime + 0.2); // C6
oscillator3.type = "sine";

// High-pass filter for bright, clear sound
filter.type = "highpass";
filter.frequency.setValueAtTime(400, ctx.currentTime);

// Volume envelope with quick attack and decay
gainNode.gain.setValueAtTime(0, ctx.currentTime);
gainNode.gain.linearRampToValueAtTime(0.12, ctx.currentTime + 0.05);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.25);

oscillator1.start(ctx.currentTime);
oscillator1.stop(ctx.currentTime + 0.25);
oscillator2.start(ctx.currentTime);
oscillator2.stop(ctx.currentTime + 0.25);
oscillator3.start(ctx.currentTime);
oscillator3.stop(ctx.currentTime + 0.25);
}

// Plays a short Web Audio sound for a keep/delete action. Always plays —
// callers decide whether sound is wanted (e.g. a settings toggle) before
// calling this.
export function playActionSound(action: FileAction): void {
const ctx = getAudioContext();
if (!ctx) return;

try {
if (action === "delete") {
playDeleteSound(ctx);
} else {
playKeepSound(ctx);
}
} catch (_error) {
// Silently fail if playback itself throws
console.log("Audio feedback not available");
}
}
Loading