Förbättrat ljudsystemet
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
101
src/app.js
101
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,
|
||||
|
||||
@@ -373,7 +373,7 @@ export function renderDriversView(deps) {
|
||||
const classOptions = state.classes.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).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) {
|
||||
<select name="classId">${classOptions}</select>
|
||||
<input name="brand" placeholder="${t("drivers.brand_placeholder")}" />
|
||||
<input name="transponder" placeholder="${t("drivers.transponder_placeholder")}" />
|
||||
<input name="speechName" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
||||
<button class="btn btn-primary" type="submit">${t("drivers.add")}</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -396,7 +397,7 @@ export function renderDriversView(deps) {
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
${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) => `
|
||||
<tr>
|
||||
@@ -404,6 +405,7 @@ export function renderDriversView(deps) {
|
||||
<td>${escapeHtml(getClassName(d.classId))}</td>
|
||||
<td>${escapeHtml(d.brand || "-")}</td>
|
||||
<td>${escapeHtml(d.transponder || "-")}</td>
|
||||
<td>${escapeHtml(d.speechName || d.name)}</td>
|
||||
<td class="actions-inline">
|
||||
<button id="driver-edit-${d.id}" class="btn">${t("common.edit")}</button>
|
||||
<button id="driver-delete-${d.id}" class="btn btn-danger">${t("common.delete")}</button>
|
||||
@@ -424,7 +426,7 @@ export function renderDriversView(deps) {
|
||||
<h3>${t("common.edit")}</h3>
|
||||
<button class="btn" id="driverEditCancel" type="button">${t("common.cancel")}</button>
|
||||
</div>
|
||||
<form id="driverEditForm" class="panel-body form-grid cols-4">
|
||||
<form id="driverEditForm" class="panel-body form-grid cols-5">
|
||||
<input name="name" required value="${escapeHtml(editingDriver.name)}" placeholder="${t("drivers.name_placeholder")}" />
|
||||
<select name="classId">
|
||||
${state.classes
|
||||
@@ -433,6 +435,7 @@ export function renderDriversView(deps) {
|
||||
</select>
|
||||
<input name="brand" value="${escapeHtml(editingDriver.brand || "")}" placeholder="${t("drivers.brand_placeholder")}" />
|
||||
<input name="transponder" value="${escapeHtml(editingDriver.transponder || "")}" placeholder="${t("drivers.transponder_placeholder")}" />
|
||||
<input name="speechName" value="${escapeHtml(editingDriver.speechName || "")}" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
||||
<p class="form-error" id="driverEditError" hidden></p>
|
||||
<div class="actions-inline">
|
||||
<button class="btn btn-primary" id="driverEditSave" type="button">${t("common.save")}</button>
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })}
|
||||
</div>
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5"], "", { t })}
|
||||
</div>
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${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 })}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) => `<option value="${escapeHtml(voice.voiceURI)}" ${state.settings.speechVoiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
|
||||
.join("");
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
@@ -80,7 +87,32 @@ export function renderSettings(deps) {
|
||||
<input type="checkbox" name="finishVoiceEnabled" form="settingsForm" ${state.settings.finishVoiceEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.finish_voice")}</span>
|
||||
</label>
|
||||
<button id="settingsTestAudio" class="btn" type="button">${t("settings.test_audio")}</button>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="startSoundEnabled" form="settingsForm" ${state.settings.startSoundEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.start_sound")}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="finishSoundEnabled" form="settingsForm" ${state.settings.finishSoundEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.finish_sound")}</span>
|
||||
</label>
|
||||
<label class="settings-audio-wide">
|
||||
<span>${t("settings.speech_voice")}</span>
|
||||
<select name="speechVoiceUri" form="settingsForm">
|
||||
<option value="">${t("settings.system_default_voice")}</option>
|
||||
${speechVoiceOptions}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("settings.speech_rate")}</span>
|
||||
<input type="range" min="0.65" max="1.35" step="0.05" name="speechRate" form="settingsForm" value="${state.settings.speechRate || 0.95}" />
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("settings.speech_pitch")}</span>
|
||||
<input type="range" min="0.7" max="1.3" step="0.05" name="speechPitch" form="settingsForm" value="${state.settings.speechPitch || 1}" />
|
||||
</label>
|
||||
<button id="settingsTestStart" class="btn" type="button">${t("settings.test_start")}</button>
|
||||
<button id="settingsTestFinish" class="btn" type="button">${t("settings.test_finish")}</button>
|
||||
<button id="settingsTestDriverVoice" class="btn" type="button">${t("settings.test_driver_voice")}</button>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="speakerPassingCueEnabled" form="settingsForm" ${state.settings.speakerPassingCueEnabled ? "checked" : ""} />
|
||||
<span>${t("settings.speaker_passing_cue")}</span>
|
||||
@@ -108,6 +140,7 @@ export function renderSettings(deps) {
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>${t("settings.audio_note")}</p>
|
||||
<p class="hint">${t("settings.voice_note")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -277,15 +310,27 @@ export function renderSettings(deps) {
|
||||
</section>
|
||||
`;
|
||||
|
||||
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(() => {
|
||||
document.getElementById("settingsTestStart")?.addEventListener("click", () => {
|
||||
applyAudioFormValues();
|
||||
if (state.settings.startSoundEnabled) playStartCue();
|
||||
if (state.settings.finishVoiceEnabled) {
|
||||
playFinishSiren();
|
||||
setTimeout(() => speakText(t("timing.race_started_voice"), { interrupt: true }), state.settings.startSoundEnabled ? 650 : 0);
|
||||
}
|
||||
}, 180);
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
64
tests/audio_runtime.test.js
Normal file
64
tests/audio_runtime.test.js
Normal file
@@ -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 }]);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user