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 {
|
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(),
|
||||||
|
|||||||
@@ -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 = {
|
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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user