From c35403546d38fd2fa5a5a162af8a79914df792b7 Mon Sep 17 00:00:00 2001 From: Dylan Meadows Date: Mon, 23 Mar 2026 22:30:49 -0400 Subject: [PATCH] feat: add audio trigger to start timer on sound detection Adds a microphone button next to the settings gear in the timer footer. When active, the app listens for audio above a silence threshold and automatically starts the timer on the first detected sound (e.g. the click of a GBA power button). Listening stops automatically once the timer starts or when the user manually deactivates it. Co-authored-by: Lucas-Rosenzweig <38187137+Lucas-Rosenzweig@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.css | 42 +++++++++++ src/App.tsx | 47 +++++++++++-- src/audio/sounds.ts | 5 ++ src/components/TimerDisplay.tsx | 73 +++++++++++++------ src/hooks/useAudio.ts | 120 ++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 src/hooks/useAudio.ts diff --git a/src/App.css b/src/App.css index 89d5f94..93484f9 100644 --- a/src/App.css +++ b/src/App.css @@ -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; @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 523bc0c..b7cbc93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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']; @@ -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(); @@ -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; @@ -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 @@ -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 (
@@ -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 */} diff --git a/src/audio/sounds.ts b/src/audio/sounds.ts index 0ae567a..7443260 100644 --- a/src/audio/sounds.ts +++ b/src/audio/sounds.ts @@ -42,6 +42,11 @@ export function resumeAudio(): void { } } +/** Fire-and-forget wrapper for use in event handlers. */ +export function resumeAudioSync(): void { + resumeAudio(); +} + // ─── Playback (fire-and-forget) ─── function playBuffer(buffer: Promise, label: string, receivedAt: number): void { diff --git a/src/components/TimerDisplay.tsx b/src/components/TimerDisplay.tsx index 33a777e..cc3000b 100644 --- a/src/components/TimerDisplay.tsx +++ b/src/components/TimerDisplay.tsx @@ -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); @@ -127,27 +133,54 @@ export function TimerDisplay({ )}
- + + + + + + +