From aaca5e8c632bc83cf1215bce8dcf6d35ae793774 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 30 Sep 2026 21:47:40 +0200 Subject: [PATCH] feat(audio): passing tone only during races and remove legacy audio Adds 'Calls during a race' (default: passing tone only, like timing systems) that drops name/personal best/leader/position calls in race sessions; practice keeps them. Removes the legacy audio panel and fallbacks, including its 'Sound on' switch that silently muted every race-audio voice. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++- ROADMAP.md | 17 +++++ docs/javascript-architecture.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/app.js | 22 ++++-- src/core_views.js | 15 ++-- src/race_audio.js | 14 ++++ src/runtime_services.js | 34 ++------- src/settings.js | 118 +------------------------------- tests/audio_runtime.test.js | 41 ++--------- tests/browser_settings_tabs.js | 13 ++++ tests/race_audio.test.js | 16 ++++- 13 files changed, 108 insertions(+), 205 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b5747..925adcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,18 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. -## 1.27.0 - 2026-09-30 +## 1.28.0 - 2026-09-30 + +### Added + +- Settings → Audio → "Calls during a race". The default, "Passing tone only (like timing systems)", plays just each car's passing tone during a race: no driver names, personal bests, fastest laps, track records, new leaders or position sounds. "Speaker" brings those calls back. Practice always keeps them. Clock calls (1 min, 30 s, 10 s), the start, the final lap and the results after the finish are not affected. + +### Removed + +- The "Audio · Legacy" settings panel (sound on, passing beep/name, start/finish tone and voice, browser voice/rate/pitch, speaker cue switches). Race audio has covered all of it for several releases. Its "Sound on" switch still silenced every race-audio voice while the effects kept playing; that trap is gone. + - With race audio turned off the app is now silent, instead of falling back to the old beeps. + - The Speaker overlay cue switches are still on the Timing page. + ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index ff5f885..3f6bb36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -614,6 +614,23 @@ Verified 2026-09-30: Not done: a configurable standard lap time per event (EVERLAPS has one). Until then, the first completed lap stands in for it. +## Race audio: passing tone only during a race, legacy audio removed + +Asked by the user 2026-09-30: is the "Audio · Legacy" panel still needed, and during a race timing systems only give a passing tone - no names read out. + +Checked first: +- With race audio on (the default), the legacy passing/start/finish settings did nothing. +- The browser voice/rate/pitch only fed the old voice. +- The speaker cue switches duplicated the ones on the Timing page. +- The legacy "Sound on" switch was a trap: `speakTextHelper` still checked it, so turning it off silenced every race-audio voice, Piper included, while the effects kept playing. + +- [x] `raceAudio.raceCallouts` is "passing" by default, or "full". `isSilencedDuringRace` in `race_audio.js` drops `personal_best`, `fastest_lap`, `track_record`, `new_leader`, `position_gain`, `position_loss` and `provisional_pole` for non-practice sessions. The check sits in `triggerRaceAudio` in `app.js`; test buttons pass `force` and are never dropped. Start, clock, final-lap and results calls are untouched, and practice keeps everything. +- [x] Removed the legacy panel, its save code and its three test buttons, the legacy passing, start and finish fallbacks (`announcePassingHelper`, and the tone and voice in `announceRaceStarted/FinishedHelper`), and the `audioEnabled` gate in `speakTextHelper`. The Dashboard audio card now shows the race audio state and the race-calls mode. Old saved values are still read but no longer used. + +Verified 2026-09-30: +- 238 unit tests pass, including new tests for the silenced set, speaker mode, practice and no session; that a saved `audioEnabled: false` no longer mutes speech; and that race start no longer plays the old tone and voice. +- The 49 browser scenarios pass. The settings test now checks that the legacy panel is gone, that race calls default to passing tone only, and that "Speaker" survives a save. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index c496a91..374578d 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -566,7 +566,7 @@ Type: - local voice playback and its settings panel Owns: -- `piper_speech.js`: `speakWithPiper` fetches the server's WAV and plays it; on any error or after 2.5 s it calls the browser-voice fallback instead. `speakTextHelper` (`runtime_services.js`) routes through it when `settings.ttsEngine === "piper"`, so legacy speaker cues and the race-audio engine both use it +- `piper_speech.js`: `speakWithPiper` fetches the server's WAV and plays it; on any error or after 2.5 s it calls the browser-voice fallback instead. `speakTextHelper` (`runtime_services.js`) routes through it when `settings.ttsEngine === "piper"`, so every race-audio voice uses it - `tts_settings_view.js`: the "Voice engine" panel in Settings -> Audio (engine choice, sv/en install status, install / pre-generate / test buttons) ### `src/track_layout_bar.js` @@ -2800,7 +2800,7 @@ The service is constructed in `src/app.js`. `src/app.js` is the composition root - `rc-race-sound-pack/sound_manifest.json`: event-to-effect mapping, `sv`/`en` templates, runtime defaults, optional passing pool names. - `server.js`: read-only `/race-sounds` static route. JSON is `no-store`; WAV files may be cached for one day. - `src/decoder_runtime.js`: emits audio metadata only after existing validation/result mutation. Split timing never emits a lap; pit roles emit `entering_pits`/`leaving_pits` without adding laps. -- `src/runtime_services.js`: legacy audio fallback, Web Speech adapter, persisted-state hydration, and one-shot 60/30/10/time-expired/final-lap markers. +- `src/runtime_services.js`: Web Speech adapter, persisted-state hydration, and one-shot 60/30/10/time-expired/final-lap markers. - `src/timing_views.js`: visual start lights and manual race-control cues. It retains the authoritative client-side start callback. - `src/settings.js`: persistent volume/language/start configuration and per-event effect/voice matrix. - `src/core_views.js`: optional `passSound` on driver and car records. diff --git a/package-lock.json b/package-lock.json index 19a1c0a..1bcecc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.27.0", + "version": "1.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.27.0", + "version": "1.28.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 3ab082e..58b2143 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.27.0", + "version": "1.28.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index a8dd899..92f3486 100644 --- a/src/app.js +++ b/src/app.js @@ -9,7 +9,7 @@ import { recordTransponderSighting, buildWarmupCheck, createPrologue, getPrologu import { findTransponderConflict, getDriverProfileId } from "./driver_profile_logic.js"; import { renderRaceDayView, refreshRaceDayWarmup, refreshRaceDayPrologueClock, updateRaceDayStartDisplay } from "./race_day_view.js"; import { beginSession as beginSessionControl, finishSession as finishSessionControl, pauseSession as pauseSessionControl, resumeSession as resumeSessionControl } from "./session_control.js"; -import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "./race_audio.js"; +import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings, isSilencedDuringRace } from "./race_audio.js"; import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; @@ -26,7 +26,7 @@ import { getSessionsForEventHelper, getModeLabelHelper, normalizeStartModeHelper import { renderEventWorkspaceView } from "./event_workspace_controller.js"; import { renderEventManagerMarkup } from "./event_manager_view.js"; import { renderEventManagerView } from "./event_manager_controller.js"; -import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startBackgroundHydrationHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; +import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startBackgroundHydrationHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildStepTrainingProgress as buildStepTrainingProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, countDriverExerciseSessions as countDriverExerciseSessionsLogic, countExerciseSessions as countExerciseSessionsLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; @@ -175,7 +175,11 @@ const playBestLapCue = () => playBestLapCueHelper({ ensureAudioContext }); const pushOverlayEvent = (type, label, options = {}) => pushOverlayEventHelper(type, label, { uid, state, t, getOverlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, getOverlayMode: () => overlayMode, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue }, options); const getSpeechVoices = () => getSpeechVoicesHelper(); const speakText = (text, options = {}) => speakTextHelper(text, { state, currentLanguage }, options); -const triggerRaceAudio = (eventKey, options = {}) => raceAudioEngine.trigger(eventKey, options); +const triggerRaceAudio = (eventKey, options = {}) => { + // The Settings test buttons use force: they must sound even with no session running. + if (!options.force && isSilencedDuringRace(eventKey, state.settings.raceAudio, getActiveSession())) return { skipped: true, voiceDone: Promise.resolve(false) }; + return raceAudioEngine.trigger(eventKey, options); +}; const getPassingSoundProfile = (entry) => { const car = state.cars.find((item) => item.id === entry?.carId); const driver = state.drivers.find((item) => item.id === entry?.driverId); @@ -184,14 +188,12 @@ const getPassingSoundProfile = (entry) => { const announcePassing = (entry) => { if (state.settings.raceAudio?.enabled) { triggerRaceAudio("transponder_pass", { variant: entry?.carId || entry?.driverId || entry?.transponder, effectPath: getPassingSoundProfile(entry), scope: entry?.key }); - return; } - announcePassingHelper(entry, { state, t, playPassingBeep, speakText }); }; -const announceRaceStarted = (session) => announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }); +const announceRaceStarted = (session) => announceRaceStartedHelper(session, { t, pushOverlayEvent }); const announceRaceFinished = () => { const session = getActiveSession(); - announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }); + announceRaceFinishedHelper({ t, getActiveSession, pushOverlayEvent }); if (!session || !state.settings.raceAudio?.enabled) return; triggerRaceAudio(session.type === "qualification" ? "qualifying_finished" : "race_finished", { interrupt: true, logData: { raceId: session.eventId, sessionId: session.id } }); const podium = buildLeaderboard(session).slice(0, 3); @@ -1701,6 +1703,9 @@ const TRANSLATIONS = { "audio.min_delay": "Minsta fördröjning (sek)", "audio.max_delay": "Största fördröjning (sek)", "audio.per_competitor": "Olika passing-ton per förare/bil", + "audio.race_callouts": "Utrop under race", + "audio.race_callouts_passing": "Bara passeringsljud (som tidtagningssystem)", + "audio.race_callouts_full": "Speaker - namn, bästa varv, ny ledare", "audio.test": "Testa", "audio.note": "Fasta effekter spelas från ljudpaketets WAV-filer. Röstutrop använder vald röstmotor ovan - lokal Piper-röst eller webbläsarens systemröster. Val sparas automatiskt; kritiska utrop avbryter lägre prioriterat tal.", "audio.effect": "Effekt", @@ -3340,6 +3345,9 @@ const TRANSLATIONS = { "audio.min_delay": "Minimum delay (seconds)", "audio.max_delay": "Maximum delay (seconds)", "audio.per_competitor": "Different passing tone per driver/car", + "audio.race_callouts": "Calls during a race", + "audio.race_callouts_passing": "Passing tone only (like timing systems)", + "audio.race_callouts_full": "Speaker - names, best laps, new leader", "audio.test": "Test", "audio.note": "Fixed effects use WAV files from the sound pack. Voice announcements use the voice engine chosen above - the local Piper voice or the browser's system voices. Changes save automatically; critical announcements interrupt lower-priority speech.", "audio.effect": "Effect", diff --git a/src/core_views.js b/src/core_views.js index 3ed16e6..d4349ae 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -73,12 +73,8 @@ export function renderDashboardView(deps) { const totalPassings = Object.values(state.resultsBySession || {}).reduce((sum, x) => sum + (x?.passings?.length || 0), 0); const backendUrl = getBackendUrl(); const decoderUrl = state.settings.wsUrl || "-"; - const audioProfile = - state.settings.passingSoundMode === "name" - ? t("settings.passing_sound_name") - : state.settings.passingSoundMode === "beep" - ? t("settings.passing_sound_beep") - : t("settings.passing_sound_off"); + const raceAudio = state.settings.raceAudio || {}; + const audioProfile = raceAudio.enabled === false ? t("common.off") : raceAudio.muted ? t("audio.mute") : t("audio.enabled"); dom.view.innerHTML = ` ${renderTrackLayoutBar({ state, t, escapeHtml, showNewButton: !active || isPracticeSessionType(active.type) })} @@ -162,8 +158,8 @@ export function renderDashboardView(deps) {
${t("dashboard.audio_profile")} - ${state.settings.audioEnabled ? audioProfile : t("settings.passing_sound_off")} - ${state.settings.finishVoiceEnabled ? t("settings.finish_voice") : "-"} + ${audioProfile} + ${t(raceAudio.raceCallouts === "full" ? "audio.race_callouts_full" : "audio.race_callouts_passing")}
${t("dashboard.schedule_drift")} @@ -228,9 +224,6 @@ export function renderDashboardView(deps) { document.getElementById("dashboardTestAudio")?.addEventListener("click", () => { ensureAudioContext(); playPassingBeep(); - if (state.settings.finishVoiceEnabled) { - setTimeout(playFinishSiren, 220); - } }); } diff --git a/src/race_audio.js b/src/race_audio.js index fb4bf23..617e4ea 100644 --- a/src/race_audio.js +++ b/src/race_audio.js @@ -84,11 +84,25 @@ export function normalizeRaceAudioSettings(value = {}) { minStartDelay, maxStartDelay, perCompetitorPassingTone: source.perCompetitorPassingTone === true, + raceCallouts: source.raceCallouts === "full" ? "full" : "passing", events, templates: source.templates && typeof source.templates === "object" ? { ...source.templates } : {}, }; } +// During a race, timing systems give each passing its tone and nothing more: no names, no +// "personal best" / "new leader" calls per lap. raceCallouts "passing" (the default) keeps it +// that way for race sessions; "full" is the speaker mode. Practice always gets the full set, +// and the clock, start, final-lap and results calls are never affected. +export const RACE_PASSING_CALLOUT_EVENTS = Object.freeze(["personal_best", "fastest_lap", "track_record", "new_leader", "position_gain", "position_loss", "provisional_pole"]); +const PRACTICE_TYPES = new Set(["open_practice", "free_practice", "practice"]); + +export function isSilencedDuringRace(eventKey, settings, session) { + if (!session || PRACTICE_TYPES.has(String(session.type || ""))) return false; + if (normalizeRaceAudioSettings(settings).raceCallouts === "full") return false; + return RACE_PASSING_CALLOUT_EVENTS.includes(eventKey); +} + const joinUrl = (baseUrl, filePath) => `${String(baseUrl || "").replace(/\/+$/, "")}/${String(filePath || "").replace(/^\/+/, "")}`; const interpolate = (template, variables = {}) => String(template || "").replace(/\{(\w+)\}/g, (_match, key) => String(variables[key] ?? "")); function safeEffectPath(value) { diff --git a/src/runtime_services.js b/src/runtime_services.js index 14d7160..ad6ebe7 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -361,14 +361,8 @@ export function selectSpeechVoice(voices, voiceUri, language) { || 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 || !text) return false; + if (!text) return false; if (state.settings.ttsEngine === "piper" && !options.skipPiper) { const piperLanguage = String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en"; return speakWithPiper(text, { @@ -402,35 +396,15 @@ export function speakTextHelper(text, { state, currentLanguage }, options = {}) 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") { - const spoken = speakText(entry?.speechName || entry?.displayName || entry?.driverName || t("common.unknown_driver"), { interrupt: false }); - if (!spoken) playPassingBeep(); - } -} - -export function announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }) { +// Race audio (race_audio.js) owns every start/finish sound; these only log the overlay event. +export function announceRaceStartedHelper(session, { t, pushOverlayEvent }) { if (!session) return; pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`, { silentCue: true }); - if (state.settings.raceAudio?.enabled) return; - 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 }) { +export function announceRaceFinishedHelper({ t, getActiveSession, pushOverlayEvent }) { const session = getActiveSession(); if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`, { silentCue: true }); - if (state.settings.raceAudio?.enabled) return; - 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, announceRaceClockEvent = () => {} }) { diff --git a/src/settings.js b/src/settings.js index 11f0d93..a30a64f 100644 --- a/src/settings.js +++ b/src/settings.js @@ -98,9 +98,6 @@ export function renderSettings(deps) { const running = Boolean(ammcStatus.running); const speechVoices = getSpeechVoices(); const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio); - const speechVoiceOptions = speechVoices - .map((voice) => ``) - .join(""); const raceAudioVoiceOptions = speechVoices .map((voice) => ``) .join(""); @@ -243,6 +240,7 @@ export function renderSettings(deps) {
+ @@ -262,78 +260,6 @@ export function renderSettings(deps) {

${t("audio.note")}

${raceAudioEventRows}
Event${t("audio.effect")}${t("audio.voice")}
-
-

${t("settings.audio")} · Legacy

-
- - - - - - - - - - - - - - - - - -
-
-

${t("settings.audio_note")}

-

${t("settings.voice_note")}

-
-
@@ -668,6 +594,7 @@ Erik Eriksson;654321;4WD;Associated;; startCountdownSeconds: Number(form.get("raceAudioCountdown")), startLights: form.get("raceAudioStartLights") === "on", randomStartDelay: form.get("raceAudioRandomDelay") === "on", minStartDelay: Number(form.get("raceAudioMinDelay")), maxStartDelay: Number(form.get("raceAudioMaxDelay")), perCompetitorPassingTone: form.get("raceAudioPerCompetitor") === "on", + raceCallouts: String(form.get("raceAudioRaceCallouts") || "passing"), events, }); }; @@ -676,7 +603,6 @@ Erik Eriksson;654321;4WD;Associated;; 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; return true; }; @@ -686,19 +612,6 @@ Erik Eriksson;654321;4WD;Associated;; return true; }; - const applyAudioFormValues = () => { - const formNode = document.getElementById("settingsForm"); - if (!(formNode instanceof HTMLFormElement)) return; - const form = new FormData(formNode); - state.settings.audioEnabled = form.get("audioEnabled") === "on"; - state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep"); - state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on"; - state.settings.startSoundEnabled = form.get("startSoundEnabled") === "on"; - state.settings.finishSoundEnabled = form.get("finishSoundEnabled") === "on"; - state.settings.speechVoiceUri = String(form.get("speechVoiceUri") || ""); - state.settings.speechRate = Math.max(0.65, Math.min(1.35, Number(form.get("speechRate") || 0.95) || 0.95)); - state.settings.speechPitch = Math.max(0.7, Math.min(1.3, Number(form.get("speechPitch") || 1) || 1)); - }; const splitDecoderToggle = document.getElementById("settingsSplitEnabled"); const pitDecoderToggle = document.getElementById("settingsPitEnabled"); @@ -743,15 +656,7 @@ Erik Eriksson;654321;4WD;Associated;; state.settings.pitExitWsUrl = String(form.get("pitExitWsUrl") || "").trim(); state.settings.backendUrl = String(form.get("backendUrl") || "").trim(); state.settings.autoReconnect = form.get("autoReconnect") === "on"; - applyAudioFormValues(); applyRaceAudioFormValues(form); - state.settings.audioEnabled = state.settings.audioEnabled || state.settings.raceAudio.enabled; - state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on"; - state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on"; - state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on"; - state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on"; - state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on"; - state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on"; state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB"; state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Timing & Race Control"; state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by RC RaceCore"; @@ -781,25 +686,6 @@ Erik Eriksson;654321;4WD;Associated;; }; 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(); - if (state.settings.finishVoiceEnabled) { - setTimeout(() => speakText(t("timing.race_started_voice"), { interrupt: true }), state.settings.startSoundEnabled ? 650 : 0); - } - }); - document.getElementById("settingsTestFinish")?.addEventListener("click", () => { - applyAudioFormValues(); - if (state.settings.finishSoundEnabled) playFinishSiren(); - if (state.settings.finishVoiceEnabled) { - setTimeout(() => speakText(t("timing.race_finished_voice"), { interrupt: true }), state.settings.finishSoundEnabled ? 1150 : 0); - } - }); - document.getElementById("settingsTestDriverVoice")?.addEventListener("click", () => { - applyAudioFormValues(); - const driver = state.drivers[0]; - speakText(driver?.speechName || driver?.name || t("settings.driver_voice_sample"), { interrupt: true }); - }); document.getElementById("settingsLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true })); document.getElementById("settingsCopyObsUrl")?.addEventListener("click", async () => { const url = buildOverlayUrl("obs", { public: true }); diff --git a/tests/audio_runtime.test.js b/tests/audio_runtime.test.js index b764460..71cb2c1 100644 --- a/tests/audio_runtime.test.js +++ b/tests/audio_runtime.test.js @@ -2,9 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - announcePassingHelper, announceRaceStartedHelper, - getSessionAnnouncementText, handleSessionTimerTickHelper, selectSpeechVoice, speakTextHelper, @@ -21,48 +19,21 @@ test("speech voice selection prefers an explicit voice and then the current lang assert.equal(selectSpeechVoice(voices, "missing", "de-DE"), null); }); -test("session announcements distinguish practice from racing", () => { - const t = (key) => key; - assert.equal(getSessionAnnouncementText({ type: "open_practice" }, "start", { t }), "timing.practice_started_voice"); - assert.equal(getSessionAnnouncementText({ type: "practice" }, "finish", { t }), "timing.practice_finished_voice"); - assert.equal(getSessionAnnouncementText({ type: "final" }, "start", { t }), "timing.race_started_voice"); - assert.equal(getSessionAnnouncementText({ type: "heat" }, "finish", { t }), "timing.race_finished_voice"); -}); - -test("passing announcement uses the driver's spoken name and beeps while speech is busy", () => { - const calls = []; - announcePassingHelper( - { displayName: "Anders", speechName: "Ann-ders" }, - { - state: { settings: { audioEnabled: true, passingSoundMode: "name" } }, - t: () => "Unknown", - speakText: (value, options) => { calls.push([value, options]); return false; }, - playPassingBeep: () => calls.push(["beep"]), - } - ); - - assert.deepEqual(calls, [["Ann-ders", { interrupt: false }], ["beep"]]); -}); - -test("race start creates an overlay event and plays enabled start cues", async () => { +test("race start only creates the overlay event - the old start tone and voice are gone (race audio owns the start)", async () => { const calls = []; announceRaceStartedHelper( { id: "session_1", name: "A Final", type: "final" }, { - state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true } }, + // Old saved settings from the removed legacy panel must not bring the extra cues back. + state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true, raceAudio: { enabled: false } } }, t: (key) => key, pushOverlayEvent: (type, label, options) => calls.push([type, label, options]), playStartCue: () => calls.push(["tone"]), speakText: (value, options) => calls.push(["speech", value, options]), } ); - - assert.deepEqual(calls, [ - ["start", "A Final • timing.start", { silentCue: true }], - ["tone"], - ]); await new Promise((resolve) => setTimeout(resolve, 700)); - assert.deepEqual(calls[2], ["speech", "timing.race_started_voice", { interrupt: true }]); + assert.deepEqual(calls, [["start", "A Final • timing.start", { silentCue: true }]]); }); test("session timer emits each race-audio clock marker once without changing finish rules", () => { @@ -111,8 +82,10 @@ test("race audio can ignore a mismatched configured voice and select the announc constructor(text) { this.text = text; } }; try { + // audioEnabled false: the removed legacy "Sound on" switch used to silence every race-audio + // voice (while effects still played). It must no longer have any effect. const started = speakTextHelper("Race finished", { - state: { settings: { audioEnabled: true, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } }, + state: { settings: { audioEnabled: false, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } }, currentLanguage: () => "sv", }, { language: "en", voiceUri: "", rate: 1.1, pitch: 0.85 }); assert.equal(started, true); diff --git a/tests/browser_settings_tabs.js b/tests/browser_settings_tabs.js index aecf3e8..95ffc99 100644 --- a/tests/browser_settings_tabs.js +++ b/tests/browser_settings_tabs.js @@ -43,4 +43,17 @@ export async function runSettingsTabsSmoke({ evaluate, navigate }) { assert.equal(visible.system, true); assert.equal(visible.connection, false); console.log("PASS the active settings tab is preserved across a save-triggered re-render"); + + // The old "Audio · Legacy" panel is gone; "Calls during a race" defaults to passing tone + // only and survives a save. + await evaluate(`document.querySelector('[data-settings-tab="audio"]').click()`); + assert.equal(await evaluate(`/Legacy/.test(document.querySelector('[data-settings-panel="audio"]').innerText)`), false, "no legacy audio panel"); + assert.equal(await evaluate(`Boolean(document.querySelector('[name="audioEnabled"], [name="passingSoundMode"]'))`), false, "no legacy sound switches"); + const callouts = () => evaluate(`document.querySelector('[name="raceAudioRaceCallouts"]').value`); + assert.equal(await callouts(), "passing"); + await evaluate(`(() => { document.querySelector('[name="raceAudioRaceCallouts"]').value = "full"; document.getElementById("settingsForm").requestSubmit(); })()`); + assert.equal(await callouts(), "full", "speaker mode is saved"); + await evaluate(`(() => { document.querySelector('[name="raceAudioRaceCallouts"]').value = "passing"; document.getElementById("settingsForm").requestSubmit(); })()`); + assert.equal(await callouts(), "passing"); + console.log("PASS the legacy audio panel is gone and race calls default to passing tone only"); } diff --git a/tests/race_audio.test.js b/tests/race_audio.test.js index 001e860..7ed52ee 100644 --- a/tests/race_audio.test.js +++ b/tests/race_audio.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "../src/race_audio.js"; +import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings, isSilencedDuringRace } from "../src/race_audio.js"; const manifest = { events: { transponder_pass: { effect: "effects/transponder_pass.wav" }, start_tone: { effect: "effects/start_beep.wav" } }, @@ -115,6 +115,20 @@ test("after the prologue a grid start skips its own count-down and intro calls", assert.deepEqual(calls, [["wait", 2000], ["effect", "start_beep.wav"], ["timing-start"]], "only the 0-5 s delay and the horn are left"); }); +test("during a race only the passing tone plays by default - no names, personal bests or leader calls", () => { + const race = { type: "heat" }; + assert.equal(normalizeRaceAudioSettings({}).raceCallouts, "passing"); + ["personal_best", "fastest_lap", "track_record", "new_leader", "position_gain", "position_loss"].forEach((key) => { + assert.equal(isSilencedDuringRace(key, {}, race), true, key); + }); + ["transponder_pass", "final_lap", "one_minute_remaining", "race_finished", "race_winner", "clock_running"].forEach((key) => { + assert.equal(isSilencedDuringRace(key, {}, race), false, `${key} is not a per-passing call`); + }); + assert.equal(isSilencedDuringRace("personal_best", { raceCallouts: "full" }, race), false, "speaker mode keeps the calls"); + assert.equal(isSilencedDuringRace("personal_best", {}, { type: "open_practice" }), false, "practice keeps the calls"); + assert.equal(isSilencedDuringRace("personal_best", {}, null), false); +}); + test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => { const calls = []; const engine = createRaceAudioEngine({