From fa3e5ee379ced56b8d0fed57add3227349a5d6ef Mon Sep 17 00:00:00 2001 From: larssand Date: Fri, 28 Aug 2026 13:39:12 +0200 Subject: [PATCH] add save sound --- README.md | 2 +- README.sv.md | 2 +- src/app.js | 6 +++--- src/runtime_services.js | 3 ++- src/settings.js | 23 ++++++++++++++++++++--- tests/audio_runtime.test.js | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 68c9cf9..e7e7125 100644 --- a/README.md +++ b/README.md @@ -541,7 +541,7 @@ The repository includes `rc-race-sound-pack/`. `sound_manifest.json` is the sour 1. Open `Settings -> Race sounds and announcements`. 2. Select `sv` or `en`, then set master, effects, voice, and passing volume. -3. Enable or disable effect and voice independently for each event and use its Test button. +3. Enable or disable effect and voice independently for each event and use its Test button. Changes save automatically, and the section also has its own Save button. WAV effects are prerecorded; voice announcements use the operating system Web Speech voices. 4. Select a 0/10/30/60-second pre-start, start lights, and random delay limits. For timed race sessions the clock starts at the same callback that initiates the start tone. Open/Free Practice starts immediately. 5. Use the Race Control buttons in `Timing` for Track call, Track clear, Red flag, False start, and Marshal warning. diff --git a/README.sv.md b/README.sv.md index c67779c..11c14fc 100644 --- a/README.sv.md +++ b/README.sv.md @@ -530,7 +530,7 @@ Repot innehåller `rc-race-sound-pack/`. `sound_manifest.json` är källan för 1. Öppna `Inställningar -> Raceljud och utrop`. 2. Välj `sv` eller `en` och ställ master-, effekt-, röst- och passing-volym. -3. Slå på/av effekt och röst separat för varje event och använd radens Testa-knapp. +3. Slå på/av effekt och röst separat för varje event och använd radens Testa-knapp. Ändringar sparas automatiskt och sektionen har även en egen Spara-knapp. WAV-effekter är förinspelade; röstutrop använder operativsystemets Web Speech-röster. 4. Välj 0/10/30/60 sekunders förstart, startljus och gränser för slumpad startfördröjning. För tidsstyrda race startar klockan i samma callback som starttonen initieras. Open/Free Practice startar direkt. 5. Använd Race Control-knapparna i `Tidtagning` för Stopp på banan, Banan fri, Röd flagg, Felaktig start och Banvaktsvarning. diff --git a/src/app.js b/src/app.js index 439b373..3c81830 100644 --- a/src/app.js +++ b/src/app.js @@ -1024,7 +1024,7 @@ const TRANSLATIONS = { "audio.max_delay": "Största fördröjning (sek)", "audio.per_competitor": "Olika passing-ton per förare/bil", "audio.test": "Testa", - "audio.note": "Effekter får överlappa och blockerar aldrig tidtagningen. Kritiska utrop avbryter lägre prioriterat tal. Saknade ljudfiler använder fallback eller hoppas över.", + "audio.note": "Fasta effekter spelas från ljudpaketets WAV-filer. Röstutrop använder webbläsarens installerade systemröster. Val sparas automatiskt; kritiska utrop avbryter lägre prioriterat tal.", "audio.effect": "Effekt", "audio.voice": "Röst", "audio.track_call": "STOPP PÅ BANAN", @@ -2131,7 +2131,7 @@ const TRANSLATIONS = { "audio.max_delay": "Maximum delay (seconds)", "audio.per_competitor": "Different passing tone per driver/car", "audio.test": "Test", - "audio.note": "Effects may overlap and never block timing. Critical announcements interrupt lower-priority speech. Missing files use a fallback or are skipped.", + "audio.note": "Fixed effects use WAV files from the sound pack. Voice announcements use the browser installed system voices. Changes save automatically; critical announcements interrupt lower-priority speech.", "audio.effect": "Effect", "audio.voice": "Voice", "audio.track_call": "TRACK CALL", @@ -2744,7 +2744,7 @@ const raceAudioEngine = createRaceAudioEngine({ speak: (text, options = {}) => new Promise((resolve) => { let settled = false; const done = (value) => { if (!settled) { settled = true; resolve(value); } }; - const started = speakText(text, { ...options, onEnd: () => done(true), onError: () => done(false) }); + const started = speakText(text, { ...options, voiceUri: "", onEnd: () => done(true), onError: () => done(false) }); if (!started) done(false); else setTimeout(() => done(true), 15000); }), diff --git a/src/runtime_services.js b/src/runtime_services.js index c8f088c..b3534b3 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -340,7 +340,8 @@ export function speakTextHelper(text, { state, currentLanguage }, options = {}) utterance.volume = Math.max(0, Math.min(1, Number(options.volume ?? 1))); if (typeof options.onEnd === "function") utterance.onend = options.onEnd; if (typeof options.onError === "function") utterance.onerror = options.onError; - const voice = selectSpeechVoice(synth.getVoices(), String(state.settings.speechVoiceUri || ""), utterance.lang); + const configuredVoiceUri = options.voiceUri !== undefined ? options.voiceUri : state.settings.speechVoiceUri; + const voice = selectSpeechVoice(synth.getVoices(), String(configuredVoiceUri || ""), utterance.lang); if (voice) utterance.voice = voice; synth.speak(utterance); return true; diff --git a/src/settings.js b/src/settings.js index 03dba9e..3e4a721 100644 --- a/src/settings.js +++ b/src/settings.js @@ -186,7 +186,7 @@ export function renderSettings(deps) { - +

${t("audio.note")}

${raceAudioEventRows}
Event${t("audio.effect")}${t("audio.voice")}
@@ -484,6 +484,15 @@ export function renderSettings(deps) { }); }; + const persistRaceAudioFormValues = () => { + const formNode = document.getElementById("settingsForm"); + if (!(formNode instanceof HTMLFormElement)) return false; + applyRaceAudioFormValues(new FormData(formNode)); + state.settings.audioEnabled = state.settings.audioEnabled || state.settings.raceAudio.enabled; + saveState(); + return true; + }; + const applyAudioFormValues = () => { const formNode = document.getElementById("settingsForm"); if (!(formNode instanceof HTMLFormElement)) return; @@ -561,8 +570,16 @@ export function renderSettings(deps) { }); document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder); - document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => triggerRaceAudio("audio_test", { force: true, interrupt: true })); - document.querySelectorAll("[data-test-race-audio]").forEach((button) => button.addEventListener("click", () => triggerRaceAudio(button.dataset.testRaceAudio, { force: true, interrupt: true, variables: { driverName: state.drivers[0]?.name || "Driver", lapTime: "16.000", marshalPost: "1", transponder: "123", podium: "P1, P2, P3" } }))); + document.querySelectorAll('[name^="raceAudio"]').forEach((control) => { + control.addEventListener("change", persistRaceAudioFormValues); + }); + document.getElementById("settingsSaveRaceAudio")?.addEventListener("click", persistRaceAudioFormValues); + const testRaceAudio = (eventKey) => { + persistRaceAudioFormValues(); + triggerRaceAudio(eventKey, { force: true, interrupt: true, variables: { driverName: state.drivers[0]?.name || "Driver", lapTime: "16.000", marshalPost: "1", transponder: "123", podium: "P1, P2, P3" } }); + }; + document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => testRaceAudio("audio_test")); + document.querySelectorAll("[data-test-race-audio]").forEach((button) => button.addEventListener("click", () => testRaceAudio(button.dataset.testRaceAudio))); document.getElementById("settingsTestStart")?.addEventListener("click", () => { applyAudioFormValues(); if (state.settings.startSoundEnabled) playStartCue(); diff --git a/tests/audio_runtime.test.js b/tests/audio_runtime.test.js index e0200cf..30b8e4b 100644 --- a/tests/audio_runtime.test.js +++ b/tests/audio_runtime.test.js @@ -7,6 +7,7 @@ import { getSessionAnnouncementText, handleSessionTimerTickHelper, selectSpeechVoice, + speakTextHelper, } from "../src/runtime_services.js"; test("speech voice selection prefers an explicit voice and then the current language", () => { @@ -88,3 +89,37 @@ test("session timer emits each race-audio clock marker once without changing fin assert.ok(active.followUpStartedAt); assert.ok(saves >= 4); }); + +test("race audio can ignore a mismatched configured voice and select the announcement language", () => { + const originalWindow = globalThis.window; + const OriginalUtterance = globalThis.SpeechSynthesisUtterance; + const spoken = []; + const voices = [ + { name: "English", lang: "en-US", voiceURI: "voice-en", localService: true }, + { name: "Swedish", lang: "sv-SE", voiceURI: "voice-sv", localService: true }, + ]; + globalThis.window = { + speechSynthesis: { + speaking: false, + pending: false, + cancel() {}, + getVoices: () => voices, + speak: (utterance) => spoken.push(utterance), + }, + }; + globalThis.SpeechSynthesisUtterance = class { + constructor(text) { this.text = text; } + }; + try { + const started = speakTextHelper("Race finished", { + state: { settings: { audioEnabled: true, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } }, + currentLanguage: () => "sv", + }, { language: "en", voiceUri: "" }); + assert.equal(started, true); + assert.equal(spoken[0].lang, "en-US"); + assert.equal(spoken[0].voice.voiceURI, "voice-en"); + } finally { + globalThis.window = originalWindow; + globalThis.SpeechSynthesisUtterance = OriginalUtterance; + } +});