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
This commit is contained in:
2026-03-29 18:16:53 +02:00
parent 7931f7ec39
commit c19b7fedc1
5 changed files with 207 additions and 0 deletions
+1
View File
@@ -125,6 +125,7 @@ function getDefaultSettings(): Settings {
return { return {
id: 1, id: 1,
audioEnabled: true, audioEnabled: true,
speechEnabled: false,
apiKey: "", apiKey: "",
apiCallsToday: { text: 0, image: 0 }, apiCallsToday: { text: 0, image: 0 },
lastApiCallDate: getTodayDate(), lastApiCallDate: getTodayDate(),
+151
View File
@@ -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<boolean> {
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<void> {
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<void> {
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<void> {
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
}
}
+53
View File
@@ -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<void> {
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
}
}
+1
View File
@@ -47,6 +47,7 @@ describe("IndexedDB wrapper", () => {
const settings: Settings = { const settings: Settings = {
id: 1, id: 1,
audioEnabled: true, audioEnabled: true,
speechEnabled: false,
apiKey: "test-key", apiKey: "test-key",
apiCallsToday: { text: 0, image: 0 }, apiCallsToday: { text: 0, image: 0 },
lastApiCallDate: "2026-03-29", lastApiCallDate: "2026-03-29",
+1
View File
@@ -53,6 +53,7 @@ export interface ForestElement {
export interface Settings { export interface Settings {
id: 1; // singleton id: 1; // singleton
audioEnabled: boolean; audioEnabled: boolean;
speechEnabled: boolean;
apiKey: string; apiKey: string;
apiCallsToday: { text: number; image: number }; apiCallsToday: { text: number; image: number };
lastApiCallDate: string; // ISO date "YYYY-MM-DD" lastApiCallDate: string; // ISO date "YYYY-MM-DD"