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:
@@ -125,6 +125,7 @@ function getDefaultSettings(): Settings {
|
||||
return {
|
||||
id: 1,
|
||||
audioEnabled: true,
|
||||
speechEnabled: false,
|
||||
apiKey: "",
|
||||
apiCallsToday: { text: 0, image: 0 },
|
||||
lastApiCallDate: getTodayDate(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user