diff --git a/README.md b/README.md index 68f0a04..53b7600 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,8 @@ Recommended deployment: - countdown during timed sessions - auto-finish at end of session time with `Race is finished` - leaderboard sorting by laps first, then nearest to target time when applicable - - browser audio for passings (`beep` or driver name) and finish + - configurable browser audio for start, finish, and passings (`beep` or driver name) + - selectable operating-system speech voice, speech rate/pitch, and per-driver phonetic spoken name - session start types: `Mass start`, `Position start`, `Staggered` - `Timing` shows grid/order for the active `Position start` session - leaderboard shows `gap to leader`, `gap ahead`, and `own delta` diff --git a/README.sv.md b/README.sv.md index ab54d37..a369783 100644 --- a/README.sv.md +++ b/README.sv.md @@ -144,7 +144,8 @@ Rekommenderad deploy: - Nedräkning under pågående session - Auto-finish vid tidslut med status `Race is finished` - Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s) - - Browserljud för passing (`blipp` eller tala förarnamn) och målgång + - Konfigurerbart browserljud för start, mål och passing (`blipp` eller tala förarnamn) + - Valbar systemröst, talhastighet/tonhöjd och separat fonetiskt uttalsnamn per förare - Sessioninställningar för `Mass start`, `Position start`, `Staggered` - `Timing` visar grid/startordning för aktiv `Position start`-session - leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 15b8f5c..26c2e7d 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -1253,8 +1253,14 @@ If one of these breaks, inspect this controller first. #### Audio/event feed - `pushOverlayEventHelper(...)` - `announcePassingHelper(...)` +- `announceRaceStartedHelper(...)` - `announceRaceFinishedHelper(...)` - `speakTextHelper(...)` +- `getSpeechVoicesHelper(...)` +- `selectSpeechVoice(...)` +- `getSessionAnnouncementText(...)` + +Start and finish lifecycle announcements are initiated from `timing_views.js`, while timer-based finish uses `handleSessionTimerTickHelper(...)`. Passing announcements use `competitor.speechName` when available. Speech does not interrupt another driver name; a busy speech queue falls back to the passing beep. Start and finish announcements deliberately interrupt pending passing speech. ### 14.6 `server.js` @@ -1322,7 +1328,8 @@ Produced/normalized by: name: string, classId: string, transponder: string, - brand: string + brand: string, + speechName: string // optional phonetic form used only by browser speech } ``` @@ -1600,6 +1607,9 @@ Practical shape contains at least: - backendUrl - decoderUrl / websocket URL - speaker/audio flags +- `audioEnabled`, `passingSoundMode`, `startSoundEnabled`, `finishSoundEnabled` +- `finishVoiceEnabled` (legacy property name; controls start/finish voice announcements) +- `speechVoiceUri`, `speechRate`, `speechPitch` - OBS overlay settings - branding defaults - race preset library @@ -2104,6 +2114,12 @@ npm test - dirty-state protection when newer local edits occur during a request - failed-sync behavior without false version advancement +`tests/audio_runtime.test.js` covers: +- explicit and language-based speech voice selection +- practice-specific versus race-specific start/finish phrases +- per-driver phonetic spoken-name selection and busy-speech beep fallback +- race-start overlay and audio cue dispatch + `tests/server_persistence.integration.test.js` starts an isolated backend with a temporary SQLite data directory and covers: - health/database path reporting - empty-state response and full app-state round-trip diff --git a/src/app.js b/src/app.js index 737914f..68cbec5 100644 --- a/src/app.js +++ b/src/app.js @@ -15,7 +15,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, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, speakTextHelper, announcePassingHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; +import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, 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, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; @@ -141,10 +141,12 @@ const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext }); const playLeaderCue = () => playLeaderCueHelper({ ensureAudioContext }); const playStartCue = () => playStartCueHelper({ ensureAudioContext }); const playBestLapCue = () => playBestLapCueHelper({ ensureAudioContext }); -const pushOverlayEvent = (type, label) => pushOverlayEventHelper(type, label, { uid, state, t, getOverlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, getOverlayMode: () => overlayMode, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue }); -const speakText = (text) => speakTextHelper(text, { state, currentLanguage }); +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 announcePassing = (entry) => announcePassingHelper(entry, { state, t, playPassingBeep, speakText }); -const announceRaceFinished = () => announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren }); +const announceRaceStarted = (session) => announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }); +const announceRaceFinished = () => announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }); const handleSessionTimerTick = () => handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, announceRaceFinished }); const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, updateHeaderState }); const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog }); @@ -196,7 +198,7 @@ const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar }); const renderGuide = () => renderGuideView({ dom, t }); const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl }); -const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, pushOverlayEvent, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); +const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); const syncTrainingLedger = () => { @@ -416,6 +418,9 @@ const TRANSLATIONS = { "drivers.brand_placeholder": "Team / märke (valfritt)", "drivers.brand_filter_placeholder": "Sök namn / transponder / brand", "drivers.transponder_placeholder": "Personlig transponder (valfritt)", + "drivers.speech_name": "Uttalsnamn", + "drivers.speech_name_placeholder": "Uttal för uppläsning (valfritt)", + "drivers.speech_name_hint": "Skriv namnet som webbläsarrösten ska uttala det. Lämna tomt för vanligt förarnamn.", "drivers.add": "Lägg till förare", "drivers.title": "Förare", "cars.create": "Skapa bil", @@ -771,7 +776,11 @@ const TRANSLATIONS = { "timing.started": "Startad", "timing.remaining": "Nedräkning", "timing.elapsed": "Körtid", - "timing.race_finished": "Race is finished", + "timing.race_finished": "Racet är avslutat", + "timing.race_started_voice": "Racet har startat", + "timing.race_finished_voice": "Racet är avslutat", + "timing.practice_started_voice": "Träningen har startat", + "timing.practice_finished_voice": "Träningen är avslutad", "timing.follow_up": "Follow-up", "timing.follow_up_active": "Follow-up aktiv", "timing.no_active": "Ingen aktiv session vald.", @@ -883,9 +892,20 @@ const TRANSLATIONS = { "settings.passing_sound_off": "Av", "settings.passing_sound_beep": "Blipp", "settings.passing_sound_name": "Säg förarnamn", - "settings.finish_voice": "Spela finish-siren", + "settings.finish_voice": "Läs upp start och avslut", + "settings.start_sound": "Startsignal", + "settings.finish_sound": "Målsignal", + "settings.speech_voice": "Talröst", + "settings.system_default_voice": "Automatisk röst för valt språk", + "settings.speech_rate": "Talhastighet", + "settings.speech_pitch": "Tonhöjd", + "settings.test_start": "Testa start", + "settings.test_finish": "Testa mål", + "settings.test_driver_voice": "Testa förarnamn", + "settings.driver_voice_sample": "Testförare", "settings.test_audio": "Testa ljud", "settings.audio_note": "Browsern kräver oftast ett klick först innan ljud/tal tillåts.", + "settings.voice_note": "Rösterna kommer från operativsystemet. På Windows ger Microsofts svenska röst oftast bäst uttal. Ange vid behov ett särskilt uttalsnamn under Förare.", "settings.branding": "Klubbinfo / PDF", "settings.club_name": "Klubbnamn", "settings.club_tagline": "Klubbtext", @@ -1008,6 +1028,12 @@ const TRANSLATIONS = { "guide.card_team_blurb": "Lagrace och endurance med team, stintlogg och summerade varv över lång tid.", "guide.card_training_blurb": "Förarutveckling, beständig varvhistorik, träningsmål och belöningar i mobilvänlig vy.", "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux och lokal SQLite-lagring.", + "guide.audio_title": "Start-, mål- och förarljud", + "guide.audio_1": "1. Öppna Inställningar -> Ljud och aktivera webbläsarljud, startsignal, målsignal och uppläsning efter behov.", + "guide.audio_2": "2. Välj en svensk systemröst och provlyssna med Testa start, Testa mål och Testa förarnamn. Webbläsaren kräver normalt att du klickar på sidan innan ljud tillåts.", + "guide.audio_3": "3. Välj Blipp eller Säg förarnamn för passeringar. Om ett namn redan läses upp används ett kort blipp i stället så att röster inte staplas ovanpå varandra.", + "guide.audio_4": "4. Om ett namn uttalas fel går du till Förare och anger Uttalsnamn, exempelvis en fonetisk stavning. Visningsnamnet ändras inte.", + "guide.audio_5": "5. Ljudet spelas i webbläsaren på datorn där Tidtagning körs. Kontrollera därför rätt ljudutgång och håll den fliken öppen under sessionen.", "guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar", "guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.", "guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare. Du kan också spara team/märke i brandfältet och filtrera listan på det senare.", @@ -1343,6 +1369,9 @@ const TRANSLATIONS = { "drivers.brand_placeholder": "Team / brand (optional)", "drivers.brand_filter_placeholder": "Search name / transponder / brand", "drivers.transponder_placeholder": "Personal transponder (optional)", + "drivers.speech_name": "Spoken name", + "drivers.speech_name_placeholder": "Pronunciation for announcements (optional)", + "drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.", "drivers.add": "Add Driver", "drivers.title": "Drivers", "cars.create": "Create Track Car", @@ -1699,6 +1728,10 @@ const TRANSLATIONS = { "timing.remaining": "Countdown", "timing.elapsed": "Elapsed", "timing.race_finished": "Race is finished", + "timing.race_started_voice": "Race started", + "timing.race_finished_voice": "Race finished", + "timing.practice_started_voice": "Practice started", + "timing.practice_finished_voice": "Practice finished", "timing.follow_up": "Follow-up", "timing.follow_up_active": "Follow-up active", "timing.no_active": "No active session selected.", @@ -1810,9 +1843,20 @@ const TRANSLATIONS = { "settings.passing_sound_off": "Off", "settings.passing_sound_beep": "Beep", "settings.passing_sound_name": "Speak driver name", - "settings.finish_voice": "Play finish siren", + "settings.finish_voice": "Announce start and finish", + "settings.start_sound": "Start signal", + "settings.finish_sound": "Finish signal", + "settings.speech_voice": "Speech voice", + "settings.system_default_voice": "Automatic voice for selected language", + "settings.speech_rate": "Speech rate", + "settings.speech_pitch": "Speech pitch", + "settings.test_start": "Test start", + "settings.test_finish": "Test finish", + "settings.test_driver_voice": "Test driver name", + "settings.driver_voice_sample": "Test driver", "settings.test_audio": "Test audio", "settings.audio_note": "Browsers usually require a click first before sound/speech is allowed.", + "settings.voice_note": "Voices are provided by the operating system. Select a matching local voice and set a separate spoken name under Drivers when pronunciation needs help.", "settings.branding": "Club Info / PDF", "settings.club_name": "Club name", "settings.club_tagline": "Club tagline", @@ -1935,6 +1979,12 @@ const TRANSLATIONS = { "guide.card_team_blurb": "Team race and endurance with teams, stint logs and total laps over long sessions.", "guide.card_training_blurb": "Driver development, durable lap history, practice goals and rewards in a mobile-friendly view.", "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux and local SQLite storage.", + "guide.audio_title": "Start, finish, and driver audio", + "guide.audio_1": "1. Open Settings -> Audio and enable browser audio, the start signal, finish signal, and voice announcements as required.", + "guide.audio_2": "2. Select a matching system voice and preview it with Test start, Test finish, and Test driver name. The browser normally requires a click before audio is allowed.", + "guide.audio_3": "3. Select Beep or Speak driver name for passings. If speech is already busy, a short beep is used instead so announcements do not overlap.", + "guide.audio_4": "4. If a name is pronounced incorrectly, open Drivers and enter a phonetic Spoken name. The displayed driver name does not change.", + "guide.audio_5": "5. Audio plays in the browser on the computer running Timing. Check that computer's audio output and keep the tab open during the session.", "guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars", "guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.", "guide.sponsor_2": "2. Add 10 drivers in Drivers. You can also store team/brand in the brand field and filter by it later.", @@ -2200,6 +2250,7 @@ function normalizeDriver(driver = {}) { classId: String(driver?.classId || "").trim(), transponder: String(driver?.transponder || "").trim(), brand: String(driver?.brand || "").trim(), + speechName: String(driver?.speechName || "").trim(), }; } @@ -2340,6 +2391,11 @@ init(); async function init() { document.body.classList.toggle("overlay-mode", overlayMode); + if ("speechSynthesis" in window) { + window.speechSynthesis.addEventListener("voiceschanged", () => { + if (currentView === "settings") renderView(); + }); + } seedDefaultData(); applyTheme(); renderNav(); @@ -2408,6 +2464,18 @@ function seedDefaultData() { state.settings.finishVoiceEnabled = true; } + if (typeof state.settings.startSoundEnabled !== "boolean") { + state.settings.startSoundEnabled = true; + } + + if (typeof state.settings.finishSoundEnabled !== "boolean") { + state.settings.finishSoundEnabled = true; + } + + state.settings.speechVoiceUri = String(state.settings.speechVoiceUri || ""); + state.settings.speechRate = Math.max(0.65, Math.min(1.35, Number(state.settings.speechRate || 0.95) || 0.95)); + state.settings.speechPitch = Math.max(0.7, Math.min(1.3, Number(state.settings.speechPitch || 1) || 1)); + if (typeof state.settings.speakerPassingCueEnabled !== "boolean") { state.settings.speakerPassingCueEnabled = false; } @@ -2489,6 +2557,11 @@ function loadState() { audioEnabled: parsed.settings?.audioEnabled !== false, passingSoundMode: parsed.settings?.passingSoundMode || "beep", finishVoiceEnabled: parsed.settings?.finishVoiceEnabled !== false, + startSoundEnabled: parsed.settings?.startSoundEnabled !== false, + finishSoundEnabled: parsed.settings?.finishSoundEnabled !== false, + speechVoiceUri: String(parsed.settings?.speechVoiceUri || ""), + speechRate: Math.max(0.65, Math.min(1.35, Number(parsed.settings?.speechRate || 0.95) || 0.95)), + speechPitch: Math.max(0.7, Math.min(1.3, Number(parsed.settings?.speechPitch || 1) || 1)), speakerPassingCueEnabled: parsed.settings?.speakerPassingCueEnabled === true, speakerLeaderCueEnabled: parsed.settings?.speakerLeaderCueEnabled !== false, speakerFinishCueEnabled: parsed.settings?.speakerFinishCueEnabled !== false, @@ -2534,6 +2607,11 @@ function loadState() { audioEnabled: true, passingSoundMode: "beep", finishVoiceEnabled: true, + startSoundEnabled: true, + finishSoundEnabled: true, + speechVoiceUri: "", + speechRate: 0.95, + speechPitch: 1, speakerPassingCueEnabled: false, speakerLeaderCueEnabled: true, speakerFinishCueEnabled: true, @@ -2720,8 +2798,8 @@ function buildDirectoryExportPayload() { function buildDriversCsvRows() { return [ - ["id", "name", "class", "brand", "transponder"], - ...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || ""]), + ["id", "name", "class", "brand", "transponder", "spoken_name"], + ...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || "", driver.speechName || ""]), ]; } @@ -2990,6 +3068,9 @@ function renderView() { connectDecoder, playPassingBeep, playFinishSiren, + playStartCue, + speakText, + getSpeechVoices, openOverlayWindow, buildOverlayUrl, normalizeObsOverlaySettings, diff --git a/src/core_views.js b/src/core_views.js index c42772b..ff2789e 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -373,7 +373,7 @@ export function renderDriversView(deps) { const classOptions = state.classes.map((c) => ``).join(""); const driverSearch = driverBrandFilter().trim().toLowerCase(); const filteredDrivers = state.drivers.filter((driver) => - !driverSearch || [driver.name, driver.transponder, driver.brand].map((value) => String(value || "").toLowerCase()).some((value) => value.includes(driverSearch)) + !driverSearch || [driver.name, driver.speechName, driver.transponder, driver.brand].map((value) => String(value || "").toLowerCase()).some((value) => value.includes(driverSearch)) ); const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId()) || null; @@ -385,6 +385,7 @@ export function renderDriversView(deps) { + @@ -396,7 +397,7 @@ export function renderDriversView(deps) {
${renderTable( - [t("table.name"), t("table.class"), t("table.brand"), t("table.transponder"), t("events.actions")], + [t("table.name"), t("table.class"), t("table.brand"), t("table.transponder"), t("drivers.speech_name"), t("events.actions")], filteredDrivers.map( (d) => ` @@ -404,6 +405,7 @@ export function renderDriversView(deps) { ${escapeHtml(getClassName(d.classId))} ${escapeHtml(d.brand || "-")} ${escapeHtml(d.transponder || "-")} + ${escapeHtml(d.speechName || d.name)} @@ -424,7 +426,7 @@ export function renderDriversView(deps) {

${t("common.edit")}

-
+ +
@@ -456,6 +459,7 @@ export function renderDriversView(deps) { classId: String(form.get("classId")), brand: String(form.get("brand") || "").trim(), transponder: String(form.get("transponder") || "").trim(), + speechName: String(form.get("speechName") || "").trim(), }) ); saveState(); @@ -522,6 +526,7 @@ export function renderDriversView(deps) { const cleanedClassId = String(form.get("classId") || "").trim(); const cleanedBrand = String(form.get("brand") || "").trim(); const cleanedTp = String(form.get("transponder") || "").trim(); + const cleanedSpeechName = String(form.get("speechName") || "").trim(); if (!cleanedName) { setFormError("driverEditError", t("validation.required_name")); return; @@ -539,6 +544,7 @@ export function renderDriversView(deps) { classId: cleanedClassId || item.classId, brand: cleanedBrand, transponder: cleanedTp, + speechName: cleanedSpeechName, }) : item ); diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index fde368f..ffeffce 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -37,6 +37,7 @@ function resolveCompetitor(session, transponder, deps) { key: `track_${assignment.id}`, driverId: driver?.id || null, driverName: driver?.name || t("common.unknown_driver"), + speechName: driver?.speechName || driver?.name || "", carId: car?.id || null, carName: car?.name || t("common.unknown_car"), }; @@ -71,6 +72,7 @@ function resolveCompetitor(session, transponder, deps) { subLabel: memberBits.join(" • ") || transponder, driverId: driver?.id || null, driverName: driver?.name || team.name, + speechName: driver?.speechName || driver?.name || team.name, carId: car?.id || null, carName: car?.name || t("common.unknown_car"), }; @@ -88,6 +90,7 @@ function resolveCompetitor(session, transponder, deps) { key: `driver_${driver.id}`, driverId: driver.id, driverName: driver.name, + speechName: driver.speechName || driver.name, displayName: driver.name, subLabel: driver.transponder || "", carId: null, @@ -250,6 +253,7 @@ export function processDecoderMessageHelper(msg, deps) { teamName: competitor.teamName || "", driverId: competitor.driverId, driverName: competitor.driverName, + speechName: competitor.speechName || competitor.driverName, displayName: competitor.displayName || competitor.driverName, subLabel: competitor.subLabel || competitor.carName || "", carId: competitor.carId, @@ -270,6 +274,7 @@ export function processDecoderMessageHelper(msg, deps) { entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || ""; entry.driverId = competitor.driverId ?? entry.driverId; entry.driverName = competitor.driverName || entry.driverName; + entry.speechName = competitor.speechName || entry.speechName || entry.driverName; entry.carId = competitor.carId ?? entry.carId; entry.carName = competitor.carName || entry.carName; entry.transponder = transponder; @@ -307,6 +312,7 @@ export function processDecoderMessageHelper(msg, deps) { teamName: entry.teamName, driverId: entry.driverId, driverName: entry.driverName, + speechName: entry.speechName, displayName: entry.displayName, subLabel: entry.subLabel, carId: entry.carId, diff --git a/src/misc_views.js b/src/misc_views.js index 94536a4..58515d7 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -67,6 +67,10 @@ export function renderGuideView({ dom, t }) { ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9"], "", { t })}
+
+ ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5"], "", { t })} +
+
${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })} ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })} diff --git a/src/runtime_services.js b/src/runtime_services.js index 5545cc2..cbb75f1 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -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 }) { diff --git a/src/settings.js b/src/settings.js index f710c7d..1d295c8 100644 --- a/src/settings.js +++ b/src/settings.js @@ -16,6 +16,9 @@ export function renderSettings(deps) { connectDecoder, playPassingBeep, playFinishSiren, + playStartCue, + speakText, + getSpeechVoices, openOverlayWindow, buildOverlayUrl, normalizeObsOverlaySettings, @@ -42,6 +45,10 @@ export function renderSettings(deps) { const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : []; const suggestedWsUrl = getManagedWsUrl(); const running = Boolean(ammcStatus.running); + const speechVoices = getSpeechVoices(); + const speechVoiceOptions = speechVoices + .map((voice) => ``) + .join(""); dom.view.innerHTML = `
@@ -80,7 +87,32 @@ export function renderSettings(deps) { ${t("settings.finish_voice")} - + + + + + + + +

${t("settings.audio_note")}

+

${t("settings.voice_note")}

@@ -277,15 +310,27 @@ export function renderSettings(deps) { `; + 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)); + }; + document.getElementById("settingsForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); state.settings.wsUrl = String(form.get("wsUrl") || "").trim(); state.settings.backendUrl = String(form.get("backendUrl") || "").trim(); state.settings.autoReconnect = form.get("autoReconnect") === "on"; - state.settings.audioEnabled = form.get("audioEnabled") === "on"; - state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep"); - state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on"; + applyAudioFormValues(); state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on"; state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on"; state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on"; @@ -303,13 +348,24 @@ export function renderSettings(deps) { }); document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder); - document.getElementById("settingsTestAudio")?.addEventListener("click", () => { - playPassingBeep(); - setTimeout(() => { - if (state.settings.finishVoiceEnabled) { - playFinishSiren(); - } - }, 180); + 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 () => { diff --git a/src/styles.css b/src/styles.css index 8e4dc42..faed6d8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -806,6 +806,10 @@ body { min-height: 40px; } +.settings-audio-wide { + grid-column: span 2; +} + .actions { display: flex; gap: 10px; @@ -2664,6 +2668,10 @@ select:focus { grid-template-columns: 1fr; } + .settings-audio-wide { + grid-column: auto; + } + .dashboard-workflow-grid, .open-practice-quickstart-body, .open-practice-quickstart-form { diff --git a/src/timing_views.js b/src/timing_views.js index c736a28..5457265 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -256,7 +256,7 @@ export function renderTimingView(deps) { getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, - ensureAudioContext, validateTrackSessionForStart, pushOverlayEvent, saveState, updateHeaderState, renderView, + ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey, getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, @@ -576,7 +576,7 @@ export function renderTimingView(deps) { lastOverlayTop3BySession[session.id] = []; setOverlayEvents([]); ensureSessionResult(session.id); - pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`); + announceRaceStarted(session); saveState(); updateHeaderState(); renderView(); @@ -591,6 +591,8 @@ export function renderTimingView(deps) { session.endedAt = Date.now(); session.finishedByTimer = false; session.followUpStartedAt = null; + setLastFinishAnnouncementSessionId(session.id); + announceRaceFinished(); saveState(); updateHeaderState(); renderView(); diff --git a/tests/audio_runtime.test.js b/tests/audio_runtime.test.js new file mode 100644 index 0000000..5ea4bad --- /dev/null +++ b/tests/audio_runtime.test.js @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + announcePassingHelper, + announceRaceStartedHelper, + getSessionAnnouncementText, + selectSpeechVoice, +} from "../src/runtime_services.js"; + +test("speech voice selection prefers an explicit voice and then the current language", () => { + const voices = [ + { name: "English", lang: "en-US", voiceURI: "voice-en", localService: true }, + { name: "Swedish", lang: "sv-SE", voiceURI: "voice-sv", localService: true }, + ]; + + assert.equal(selectSpeechVoice(voices, "voice-en", "sv-SE")?.voiceURI, "voice-en"); + assert.equal(selectSpeechVoice(voices, "", "sv-SE")?.voiceURI, "voice-sv"); + 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 () => { + const calls = []; + announceRaceStartedHelper( + { id: "session_1", name: "A Final", type: "final" }, + { + state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true } }, + 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 }]); +}); diff --git a/tests/run.js b/tests/run.js index b5ab881..4e49c48 100644 --- a/tests/run.js +++ b/tests/run.js @@ -3,4 +3,5 @@ import "./public_training_profile.test.js"; import "./race_timing_logic.test.js"; import "./judging_logic.test.js"; import "./runtime_persistence.test.js"; +import "./audio_runtime.test.js"; import "./server_persistence.integration.test.js";