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
42 changes: 42 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
min-height: 52px;
}

.timer-footer-controls {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}

/* Small settings gear */
.timer-settings-btn {
width: 28px;
Expand Down Expand Up @@ -84,6 +91,41 @@
cursor: not-allowed;
}

.timer-mic-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-faint);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
color 0.15s,
background 0.15s;
}

.timer-mic-btn:hover:not(:disabled) {
color: var(--color-btn-text);
background: var(--color-settings-hover-bg);
}

.timer-mic-btn.active {
color: #53b36c;
}

.timer-mic-btn.detected {
color: #e8a838;
}

.timer-mic-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}

/* GitHub / sponsor links */
.timer-display-links {
display: flex;
Expand Down
47 changes: 43 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
import { useAppStore, useSettingsStore } from './store';
import { usePhaseRunner } from './hooks/usePhaseRunner';
import { useAudio } from './hooks/useAudio';
import { useWakeLock } from './hooks/useWakeLock';
import { useTheme } from './hooks/useTheme';
import { useUrlParams } from './hooks/useUrlParams';
Expand All @@ -11,6 +12,7 @@ import { Gen4Panel } from './components/Gen4Panel';
import { Gen3Panel } from './components/Gen3Panel';
import { CustomPanel } from './components/CustomPanel';
import { SettingsDialog } from './components/SettingsDialog';
import { resumeAudioSync } from './audio/sounds';
import './App.css';

const TAB_LABELS = ['Gen 5', 'Gen 4', 'Gen 3', 'Custom'];
Expand All @@ -27,7 +29,16 @@ export default function App() {
const running = useAppStore((s) => s.running);
const setPhases = useAppStore((s) => s.setPhases);

const { toggle, registerFlash } = usePhaseRunner();
const { start, toggle, registerFlash } = usePhaseRunner();
const { isListening, isDetected, startListening, stopListening } = useAudio({
onDetect: () => {
if (useAppStore.getState().running) return;
start();
stopListening();
setStatusMessage('Sound detected — timer started.');
setTimeout(() => setStatusMessage('Ready'), 4000);
},
});
useWakeLock();
useUrlParams();
useTheme();
Expand Down Expand Up @@ -79,7 +90,7 @@ export default function App() {
setStatusMessage('Calibration applied.');
setTimeout(() => setStatusMessage('Ready'), 4000);
}
}, [running, currentRef, updatePhases]);
}, [running, currentRef, updatePhases, setStatusMessage]);

const handleReset = useCallback(() => {
if (running) return;
Expand All @@ -90,15 +101,33 @@ export default function App() {
}, [running, currentRef, updatePhases]);

const handleToggle = useCallback(() => {
if (!running && isListening) {
stopListening();
}
toggle();
}, [toggle]);
}, [running, isListening, stopListening, toggle]);

const handleAudioToggle = useCallback(() => {
if (running) return;
if (isListening) {
stopListening();
setStatusMessage('Audio trigger stopped.');
} else {
resumeAudioSync();
startListening().then(
() => setStatusMessage('Listening for sound to start timer...'),
(err: unknown) =>
setStatusMessage(`Microphone error: ${err instanceof Error ? err.message : err}`),
);
}
}, [running, isListening, startListening, stopListening, setStatusMessage]);

const handleSettingsClose = useCallback(
(accepted: boolean) => {
setSettingsOpen(false);
if (accepted) setTimeout(updatePhases, 0);
},
[updatePhases],
[updatePhases, setSettingsOpen],
);

// Keyboard shortcuts
Expand Down Expand Up @@ -133,6 +162,13 @@ export default function App() {
prevRunning.current = running;
}, [running]);

// Stop microphone when a run becomes active.
useEffect(() => {
if (running && isListening) {
stopListening();
}
}, [running, isListening, stopListening]);

return (
<div className="app">
<div className="app-container">
Expand All @@ -141,7 +177,10 @@ export default function App() {
registerFlash={registerFlash}
onToggle={handleToggle}
onSettings={() => setSettingsOpen(true)}
onToggleAudioListening={handleAudioToggle}
settingsDisabled={running}
audioListening={isListening}
audioDetected={isDetected}
/>

{/* Tab panel */}
Expand Down
5 changes: 5 additions & 0 deletions src/audio/sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ function ensureRunning(): Promise<void> {
return resumePromise;
}

/** Fire-and-forget wrapper for use in event handlers. */
export function resumeAudioSync(): void {
resumeAudio();
}

// ─── Playback (fire-and-forget) ───

function playBuffer(buffer: Promise<AudioBuffer>, label: string, receivedAt: number): void {
Expand Down
73 changes: 53 additions & 20 deletions src/components/TimerDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,20 @@ interface TimerDisplayProps {
registerFlash: (fn: () => void) => void;
onToggle: () => void;
onSettings: () => void;
onToggleAudioListening: () => void;
settingsDisabled: boolean;
audioListening: boolean;
audioDetected: boolean;
}

export function TimerDisplay({
registerFlash,
onToggle,
onSettings,
onToggleAudioListening,
settingsDisabled,
audioListening,
audioDetected,
}: TimerDisplayProps) {
const phases = useAppStore((s) => s.phases);
const currentPhaseIndex = useAppStore((s) => s.currentPhaseIndex);
Expand Down Expand Up @@ -127,27 +133,54 @@ export function TimerDisplay({
)}
</div>
<div className="timer-display-footer">
<button
className="timer-settings-btn"
onClick={onSettings}
disabled={settingsDisabled}
title="Open Settings (Ctrl+,)"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'block' }}
<div className="timer-footer-controls">
<button
className="timer-settings-btn"
onClick={onSettings}
disabled={settingsDisabled}
title="Open Settings (Ctrl+,)"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'block' }}
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<button
className={`timer-mic-btn${audioListening ? ' active' : ''}${audioDetected ? ' detected' : ''}`}
onClick={onToggleAudioListening}
disabled={settingsDisabled}
title={audioListening ? 'Stop audio trigger' : 'Start audio trigger'}
Comment thread
Lucas-Rosenzweig marked this conversation as resolved.
aria-label={audioListening ? 'Stop audio trigger' : 'Start audio trigger'}
aria-pressed={audioListening}
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'block' }}
>
<path d="M12 1a3 3 0 0 1 3 3v8a3 3 0 0 1-6 0V4a3 3 0 0 1 3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</button>
</div>
<button
className="timer-play-stop"
onClick={onToggle}
Expand Down
120 changes: 120 additions & 0 deletions src/hooks/useAudio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from 'react';

const THRESHOLD = 60;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make THRESHOLD configurable via settings rather than hard-coding the value so users can tune the detection sensitivity

const SILENCE_CENTER = 128;

interface UseAudioOptions {
onDetect?: () => void;
threshold?: number;
}

export const useAudio = (options?: UseAudioOptions) => {
const [isDetected, setIsDetected] = useState(false);
const [isListening, setIsListening] = useState(false);

const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const requestRef = useRef<number | null>(null);
const dataArrayRef = useRef<Uint8Array | null>(null);
const detectedRef = useRef(false);
const startingRef = useRef(false);
const onDetectRef = useRef<UseAudioOptions['onDetect']>(options?.onDetect);

useEffect(() => {
onDetectRef.current = options?.onDetect;
}, [options?.onDetect]);

function calculateMaxDeviation(dataArray: Uint8Array) {
let maxDeviation = 0;
for (let i = 0; i < dataArray.length; i++) {
const deviation = Math.abs(dataArray[i] - SILENCE_CENTER);
if (deviation > maxDeviation) {
maxDeviation = deviation;
}
}
return maxDeviation;
}

const analyzeSound = useCallback(() => {
const analyser = analyserRef.current;
if (!analyser) return;

const dataArray = dataArrayRef.current!;
analyser.getByteTimeDomainData(dataArray);

const maxDeviation = calculateMaxDeviation(dataArray);
const threshold = options?.threshold ?? THRESHOLD;
if (maxDeviation > threshold && !detectedRef.current) {
detectedRef.current = true;
setIsDetected(true);
onDetectRef.current?.();
return;
}

if (!analyserRef.current) return;
requestRef.current = requestAnimationFrame(analyzeSound);
}, [options?.threshold]);

const stopListening = useCallback(() => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}

sourceRef.current?.disconnect();
sourceRef.current = null;

analyserRef.current?.disconnect();
analyserRef.current = null;

streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;

if (audioContextRef.current) {
void audioContextRef.current.close();
audioContextRef.current = null;
}

dataArrayRef.current = null;
detectedRef.current = false;
setIsListening(false);
}, []);

const startListening = async () => {
if (isListening || startingRef.current) return;

if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Microphone access requires a secure context (HTTPS or localhost).');
}

startingRef.current = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

const audioContext = new window.AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);

source.connect(analyser);

audioContextRef.current = audioContext;
analyserRef.current = analyser;
sourceRef.current = source;
streamRef.current = stream;
dataArrayRef.current = new Uint8Array(analyser.fftSize);

detectedRef.current = false;
setIsListening(true);
setIsDetected(false);
analyzeSound();
} finally {
startingRef.current = false;
}
};

useEffect(() => stopListening, [stopListening]);

return { isListening, isDetected, startListening, stopListening };
};