Förbättrat ljudsystemet

This commit is contained in:
larssand
2026-08-24 19:40:39 +02:00
parent 2e6866a013
commit 355a1ad3b0
13 changed files with 345 additions and 47 deletions

View File

@@ -104,6 +104,11 @@ export function applyPersistedStateHelper(persisted, deps) {
audioEnabled: persisted.settings?.audioEnabled !== false,
passingSoundMode: persisted.settings?.passingSoundMode || state.settings.passingSoundMode || "beep",
finishVoiceEnabled: persisted.settings?.finishVoiceEnabled !== false,
startSoundEnabled: persisted.settings?.startSoundEnabled !== false,
finishSoundEnabled: persisted.settings?.finishSoundEnabled !== false,
speechVoiceUri: String(persisted.settings?.speechVoiceUri || state.settings.speechVoiceUri || ""),
speechRate: Math.max(0.65, Math.min(1.35, Number(persisted.settings?.speechRate || state.settings.speechRate || 0.95) || 0.95)),
speechPitch: Math.max(0.7, Math.min(1.3, Number(persisted.settings?.speechPitch || state.settings.speechPitch || 1) || 1)),
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
@@ -255,18 +260,21 @@ function playTone({ ensureAudioContext, type, startFreq, ramps = [], attack = 0.
osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + stopAt);
}
export function playPassingBeepHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'square', startFreq: 1320, peak: 0.08, release: 0.14, stopAt: 0.16 }); }
export function playFinishSirenHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sawtooth', startFreq: 720, peak: 0.12, release: 1.2, stopAt: 1.22, attack: 0.03, ramps: [[1280,0.28,'linear'],[720,0.56,'linear'],[1280,0.84,'linear'],[720,1.12,'linear']] }); }
export function playPassingBeepHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1480, peak: 0.07, release: 0.12, stopAt: 0.14, ramps: [[1760,0.08,'linear']] }); }
export function playFinishSirenHelper({ ensureAudioContext }) {
playTone({ ensureAudioContext, type: 'triangle', startFreq: 980, peak: 0.12, release: 0.58, stopAt: 0.62, attack: 0.02, ramps: [[620,0.56,'linear']] });
setTimeout(() => playTone({ ensureAudioContext, type: 'triangle', startFreq: 980, peak: 0.1, release: 0.72, stopAt: 0.76, attack: 0.02, ramps: [[520,0.68,'linear']] }), 520);
}
export function playLeaderCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'triangle', startFreq: 880, peak: 0.09, release: 0.26, stopAt: 0.28, ramps: [[1320,0.12,'linear'],[1760,0.24,'linear']] }); }
export function playStartCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'triangle', startFreq: 520, peak: 0.08, release: 0.4, stopAt: 0.42, ramps: [[1040,0.4,'linear']] }); }
export function playStartCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'square', startFreq: 760, peak: 0.1, release: 0.62, stopAt: 0.66, attack: 0.015, ramps: [[1520,0.16,'linear'],[1520,0.54,'linear']] }); }
export function playBestLapCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1540, peak: 0.07, release: 0.22, stopAt: 0.24, ramps: [[1980,0.2,'linear']] }); }
export function pushOverlayEventHelper(type, label, deps) {
export function pushOverlayEventHelper(type, label, deps, options = {}) {
const { uid, state, t, getOverlayEvents, setOverlayEvents, getOverlayMode, getCurrentView, getOverlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue } = deps;
const events = getOverlayEvents();
events.unshift({ id: uid("overlay"), type, label, ts: Date.now() });
setOverlayEvents(events.length > 12 ? events.slice(0, 12) : events);
if (getOverlayMode() && getCurrentView() === "overlay" && getOverlayViewMode() === "speaker") {
if (!options.silentCue && getOverlayMode() && getCurrentView() === "overlay" && getOverlayViewMode() === "speaker") {
if (type === "leader" && state.settings.speakerLeaderCueEnabled) playLeaderCue();
else if (type === "passing" && state.settings.speakerPassingCueEnabled) playPassingBeep();
else if (type === "finish" && state.settings.speakerFinishCueEnabled) playFinishSiren();
@@ -276,30 +284,74 @@ export function pushOverlayEventHelper(type, label, deps) {
}
}
export function speakTextHelper(text, { state, currentLanguage }) {
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) return;
export function getSpeechVoicesHelper() {
if (!("speechSynthesis" in window)) return [];
return window.speechSynthesis.getVoices().map((voice) => ({
name: voice.name,
lang: voice.lang,
voiceURI: voice.voiceURI,
localService: voice.localService,
}));
}
export function selectSpeechVoice(voices, voiceUri, language) {
const items = Array.isArray(voices) ? voices : [];
const selected = items.find((voice) => voice.voiceURI === voiceUri);
if (selected) return selected;
const prefix = String(language || "").toLowerCase().split("-")[0];
return items.find((voice) => String(voice.lang || "").toLowerCase().startsWith(prefix) && voice.localService !== false)
|| items.find((voice) => String(voice.lang || "").toLowerCase().startsWith(prefix))
|| null;
}
export function getSessionAnnouncementText(session, phase, { t }) {
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || "").toLowerCase());
if (phase === "start") return t(practice ? "timing.practice_started_voice" : "timing.race_started_voice");
return t(practice ? "timing.practice_finished_voice" : "timing.race_finished_voice");
}
export function speakTextHelper(text, { state, currentLanguage }, options = {}) {
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) return false;
const synth = window.speechSynthesis;
if (!options.interrupt && (synth.speaking || synth.pending)) return false;
if (options.interrupt) synth.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = currentLanguage() === "sv" ? "sv-SE" : "en-US";
utterance.rate = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
utterance.rate = Math.max(0.65, Math.min(1.35, Number(state.settings.speechRate || 0.95) || 0.95));
utterance.pitch = Math.max(0.7, Math.min(1.3, Number(state.settings.speechPitch || 1) || 1));
const voice = selectSpeechVoice(synth.getVoices(), String(state.settings.speechVoiceUri || ""), utterance.lang);
if (voice) utterance.voice = voice;
synth.speak(utterance);
return true;
}
export function announcePassingHelper(entry, { state, t, playPassingBeep, speakText }) {
if (!state.settings.audioEnabled) return;
if (state.settings.passingSoundMode === "beep") { playPassingBeep(); return; }
if (state.settings.passingSoundMode === "name") speakText(entry?.displayName || entry?.driverName || t("common.unknown_driver"));
if (state.settings.passingSoundMode === "name") {
const spoken = speakText(entry?.speechName || entry?.displayName || entry?.driverName || t("common.unknown_driver"), { interrupt: false });
if (!spoken) playPassingBeep();
}
}
export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren }) {
if (!state.settings.audioEnabled || !state.settings.finishVoiceEnabled) {
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`);
return;
export function announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }) {
if (!session) return;
pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`, { silentCue: true });
if (!state.settings.audioEnabled) return;
if (state.settings.startSoundEnabled !== false) playStartCue();
if (state.settings.finishVoiceEnabled !== false) {
setTimeout(() => speakText(getSessionAnnouncementText(session, "start", { t }), { interrupt: true }), state.settings.startSoundEnabled === false ? 0 : 650);
}
}
export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }) {
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`);
playFinishSiren();
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`, { silentCue: true });
if (!session || !state.settings.audioEnabled) return;
if (state.settings.finishSoundEnabled !== false) playFinishSiren();
if (state.settings.finishVoiceEnabled !== false) {
setTimeout(() => speakText(getSessionAnnouncementText(session, "finish", { t }), { interrupt: true }), state.settings.finishSoundEnabled === false ? 0 : 1150);
}
}
export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId, announceRaceFinished }) {