From c19b7fedc19fa22a6498eb55dccd648d7a10f3e0 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Sun, 29 Mar 2026 18:16:53 +0200 Subject: [PATCH] feat(04-01): add audio/speech modules and speechEnabled to Settings - Create src/audio/sounds.ts with playCorrectSound, playRewardSound, playForestElementSound - Create src/audio/speech.ts with speakText via Web Speech API - Add speechEnabled field to Settings interface in types.ts - Add speechEnabled defaults in all Settings initializers (gemini.ts, parent.ts, db.test.ts) - All sound functions check audioEnabled, speakText checks speechEnabled - AudioContext lazy initialization for autoplay policy compliance --- src/api/gemini.ts | 1 + src/audio/sounds.ts | 151 +++++++++++++++++++++++++++++++++++++++++ src/audio/speech.ts | 53 +++++++++++++++ src/storage/db.test.ts | 1 + src/types.ts | 1 + 5 files changed, 207 insertions(+) create mode 100644 src/audio/sounds.ts create mode 100644 src/audio/speech.ts diff --git a/src/api/gemini.ts b/src/api/gemini.ts index 8bafda2..9b0da1e 100644 --- a/src/api/gemini.ts +++ b/src/api/gemini.ts @@ -125,6 +125,7 @@ function getDefaultSettings(): Settings { return { id: 1, audioEnabled: true, + speechEnabled: false, apiKey: "", apiCallsToday: { text: 0, image: 0 }, lastApiCallDate: getTodayDate(), diff --git a/src/audio/sounds.ts b/src/audio/sounds.ts new file mode 100644 index 0000000..8be1a96 --- /dev/null +++ b/src/audio/sounds.ts @@ -0,0 +1,151 @@ +import { getSettings } from "../storage/db"; +import { getDB } from "../app"; + +let audioCtx: AudioContext | null = null; + +/** + * Creates or resumes AudioContext on first user interaction. + * Call from a user-interaction event handler (click, keydown) to comply + * with browser autoplay policy. + */ +export function initAudioOnInteraction(): void { + try { + if (!audioCtx) { + audioCtx = new AudioContext(); + } + if (audioCtx.state === "suspended") { + audioCtx.resume(); + } + } catch { + // AudioContext not supported — silent fallback + } +} + +async function isAudioEnabled(): Promise { + try { + const db = getDB(); + const settings = await getSettings(db); + return settings?.audioEnabled !== false; + } catch { + return true; // default to enabled if settings unavailable + } +} + +function ensureAudioCtx(): AudioContext | null { + if (!audioCtx) { + initAudioOnInteraction(); + } + return audioCtx; +} + +/** + * Short confirmation beep: 800Hz sine, 0.1s, gain 0.15. + */ +export async function playCorrectSound(): Promise { + try { + if (!(await isAudioEnabled())) return; + const ctx = ensureAudioCtx(); + if (!ctx) return; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = "sine"; + osc.frequency.value = 800; + gain.gain.value = 0.15; + + osc.connect(gain); + gain.connect(ctx.destination); + + const now = ctx.currentTime; + // Ramp gain to 0 over last 0.02s for click-free cutoff + gain.gain.setValueAtTime(0.15, now); + gain.gain.linearRampToValueAtTime(0, now + 0.1); + + osc.start(now); + osc.stop(now + 0.1); + } catch { + // Never throw to caller + } +} + +/** + * Ascending reward tone sequence: C5 E5 G5 C6, ~0.8s total. + */ +export async function playRewardSound(): Promise { + try { + if (!(await isAudioEnabled())) return; + const ctx = ensureAudioCtx(); + if (!ctx) return; + + const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6 + const noteSpacing = 0.2; + const noteDuration = 0.15; + const now = ctx.currentTime; + + for (let i = 0; i < notes.length; i++) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = "sine"; + osc.frequency.value = notes[i]!; + gain.gain.value = 0.12; + + osc.connect(gain); + gain.connect(ctx.destination); + + const startTime = now + i * noteSpacing; + gain.gain.setValueAtTime(0.12, startTime); + gain.gain.linearRampToValueAtTime(0, startTime + noteDuration); + + osc.start(startTime); + osc.stop(startTime + noteDuration); + } + } catch { + // Never throw to caller + } +} + +/** + * Forest element sound: filtered white noise, bandpass 3000Hz, 0.5s. + */ +export async function playForestElementSound(): Promise { + try { + if (!(await isAudioEnabled())) return; + const ctx = ensureAudioCtx(); + if (!ctx) return; + + // Create white noise buffer + const bufferSize = ctx.sampleRate * 0.5; // 0.5 seconds + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + + const source = ctx.createBufferSource(); + source.buffer = buffer; + + const filter = ctx.createBiquadFilter(); + filter.type = "bandpass"; + filter.frequency.value = 3000; + filter.Q.value = 0.5; + + const gain = ctx.createGain(); + gain.gain.value = 0.1; + + source.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + + const now = ctx.currentTime; + // Ramp down gain over duration + gain.gain.setValueAtTime(0.1, now); + gain.gain.linearRampToValueAtTime(0, now + 0.5); + + source.start(now); + source.stop(now + 0.5); + } catch { + // Never throw to caller + } +} diff --git a/src/audio/speech.ts b/src/audio/speech.ts new file mode 100644 index 0000000..b503972 --- /dev/null +++ b/src/audio/speech.ts @@ -0,0 +1,53 @@ +import { getSettings } from "../storage/db"; +import { getDB } from "../app"; + +let cachedVoice: SpeechSynthesisVoice | null = null; + +function findGermanVoice(): SpeechSynthesisVoice | null { + if (cachedVoice) return cachedVoice; + if (!window.speechSynthesis) return null; + + const voices = window.speechSynthesis.getVoices(); + const voice = voices.find((v) => v.lang.startsWith("de")) ?? null; + if (voice) { + cachedVoice = voice; + } + return voice; +} + +// Voices may load async — cache when ready +if (typeof window !== "undefined" && window.speechSynthesis) { + window.speechSynthesis.onvoiceschanged = () => { + findGermanVoice(); + }; + // Try immediately (some browsers have voices ready synchronously) + findGermanVoice(); +} + +/** + * Speak German text via Web Speech API. + * Checks speechEnabled setting. Silent fallback on any error. + */ +export async function speakText(text: string): Promise { + try { + if (!window.speechSynthesis) return; + + const db = getDB(); + const settings = await getSettings(db); + if (settings?.speechEnabled !== true) return; + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "de-DE"; + utterance.rate = 0.9; + utterance.pitch = 1.1; + + const voice = findGermanVoice(); + if (voice) { + utterance.voice = voice; + } + + window.speechSynthesis.speak(utterance); + } catch { + // Silent fallback — never throw to caller + } +} diff --git a/src/storage/db.test.ts b/src/storage/db.test.ts index 9cf95da..afa6354 100644 --- a/src/storage/db.test.ts +++ b/src/storage/db.test.ts @@ -47,6 +47,7 @@ describe("IndexedDB wrapper", () => { const settings: Settings = { id: 1, audioEnabled: true, + speechEnabled: false, apiKey: "test-key", apiCallsToday: { text: 0, image: 0 }, lastApiCallDate: "2026-03-29", diff --git a/src/types.ts b/src/types.ts index 0d39db8..08363b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export interface ForestElement { export interface Settings { id: 1; // singleton audioEnabled: boolean; + speechEnabled: boolean; apiKey: string; apiCallsToday: { text: number; image: number }; lastApiCallDate: string; // ISO date "YYYY-MM-DD"