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) {