add save sound

This commit is contained in:
larssand
2026-08-28 13:39:12 +02:00
parent 5f79228941
commit fa3e5ee379
6 changed files with 62 additions and 9 deletions

View File

@@ -541,7 +541,7 @@ The repository includes `rc-race-sound-pack/`. `sound_manifest.json` is the sour
1. Open `Settings -> Race sounds and announcements`. 1. Open `Settings -> Race sounds and announcements`.
2. Select `sv` or `en`, then set master, effects, voice, and passing volume. 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. 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. 5. Use the Race Control buttons in `Timing` for Track call, Track clear, Red flag, False start, and Marshal warning.

View File

@@ -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`. 1. Öppna `Inställningar -> Raceljud och utrop`.
2. Välj `sv` eller `en` och ställ master-, effekt-, röst- och passing-volym. 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. 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. 5. Använd Race Control-knapparna i `Tidtagning` för Stopp på banan, Banan fri, Röd flagg, Felaktig start och Banvaktsvarning.

View File

@@ -1024,7 +1024,7 @@ const TRANSLATIONS = {
"audio.max_delay": "Största fördröjning (sek)", "audio.max_delay": "Största fördröjning (sek)",
"audio.per_competitor": "Olika passing-ton per förare/bil", "audio.per_competitor": "Olika passing-ton per förare/bil",
"audio.test": "Testa", "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.effect": "Effekt",
"audio.voice": "Röst", "audio.voice": "Röst",
"audio.track_call": "STOPP PÅ BANAN", "audio.track_call": "STOPP PÅ BANAN",
@@ -2131,7 +2131,7 @@ const TRANSLATIONS = {
"audio.max_delay": "Maximum delay (seconds)", "audio.max_delay": "Maximum delay (seconds)",
"audio.per_competitor": "Different passing tone per driver/car", "audio.per_competitor": "Different passing tone per driver/car",
"audio.test": "Test", "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.effect": "Effect",
"audio.voice": "Voice", "audio.voice": "Voice",
"audio.track_call": "TRACK CALL", "audio.track_call": "TRACK CALL",
@@ -2744,7 +2744,7 @@ const raceAudioEngine = createRaceAudioEngine({
speak: (text, options = {}) => new Promise((resolve) => { speak: (text, options = {}) => new Promise((resolve) => {
let settled = false; let settled = false;
const done = (value) => { if (!settled) { settled = true; resolve(value); } }; 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); if (!started) done(false);
else setTimeout(() => done(true), 15000); else setTimeout(() => done(true), 15000);
}), }),

View File

@@ -340,7 +340,8 @@ export function speakTextHelper(text, { state, currentLanguage }, options = {})
utterance.volume = Math.max(0, Math.min(1, Number(options.volume ?? 1))); utterance.volume = Math.max(0, Math.min(1, Number(options.volume ?? 1)));
if (typeof options.onEnd === "function") utterance.onend = options.onEnd; if (typeof options.onEnd === "function") utterance.onend = options.onEnd;
if (typeof options.onError === "function") utterance.onerror = options.onError; 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; if (voice) utterance.voice = voice;
synth.speak(utterance); synth.speak(utterance);
return true; return true;

View File

@@ -186,7 +186,7 @@ export function renderSettings(deps) {
<label><span>${t("audio.min_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMinDelay" form="settingsForm" value="${raceAudio.minStartDelay}" /></label> <label><span>${t("audio.min_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMinDelay" form="settingsForm" value="${raceAudio.minStartDelay}" /></label>
<label><span>${t("audio.max_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMaxDelay" form="settingsForm" value="${raceAudio.maxStartDelay}" /></label> <label><span>${t("audio.max_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMaxDelay" form="settingsForm" value="${raceAudio.maxStartDelay}" /></label>
<label class="toggle"><input type="checkbox" name="raceAudioPerCompetitor" form="settingsForm" ${raceAudio.perCompetitorPassingTone ? "checked" : ""} /><span>${t("audio.per_competitor")}</span></label> <label class="toggle"><input type="checkbox" name="raceAudioPerCompetitor" form="settingsForm" ${raceAudio.perCompetitorPassingTone ? "checked" : ""} /><span>${t("audio.per_competitor")}</span></label>
<button id="settingsRaceAudioTest" class="btn" type="button">${t("audio.test")}</button> <div class="actions-inline"><button id="settingsSaveRaceAudio" class="btn btn-primary" type="button">${t("common.save")}</button><button id="settingsRaceAudioTest" class="btn" type="button">${t("audio.test")}</button></div>
</div> </div>
<div class="panel-body"><p class="hint">${t("audio.note")}</p><div class="table-wrap"><table class="audio-event-table"><thead><tr><th>Event</th><th>${t("audio.effect")}</th><th>${t("audio.voice")}</th><th></th></tr></thead><tbody>${raceAudioEventRows}</tbody></table></div></div> <div class="panel-body"><p class="hint">${t("audio.note")}</p><div class="table-wrap"><table class="audio-event-table"><thead><tr><th>Event</th><th>${t("audio.effect")}</th><th>${t("audio.voice")}</th><th></th></tr></thead><tbody>${raceAudioEventRows}</tbody></table></div></div>
</section> </section>
@@ -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 applyAudioFormValues = () => {
const formNode = document.getElementById("settingsForm"); const formNode = document.getElementById("settingsForm");
if (!(formNode instanceof HTMLFormElement)) return; if (!(formNode instanceof HTMLFormElement)) return;
@@ -561,8 +570,16 @@ export function renderSettings(deps) {
}); });
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder); document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => triggerRaceAudio("audio_test", { force: true, interrupt: true })); document.querySelectorAll('[name^="raceAudio"]').forEach((control) => {
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" } }))); 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", () => { document.getElementById("settingsTestStart")?.addEventListener("click", () => {
applyAudioFormValues(); applyAudioFormValues();
if (state.settings.startSoundEnabled) playStartCue(); if (state.settings.startSoundEnabled) playStartCue();

View File

@@ -7,6 +7,7 @@ import {
getSessionAnnouncementText, getSessionAnnouncementText,
handleSessionTimerTickHelper, handleSessionTimerTickHelper,
selectSpeechVoice, selectSpeechVoice,
speakTextHelper,
} from "../src/runtime_services.js"; } from "../src/runtime_services.js";
test("speech voice selection prefers an explicit voice and then the current language", () => { 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(active.followUpStartedAt);
assert.ok(saves >= 4); 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;
}
});