Add configurable manifest-driven race audio system
This commit is contained in:
174
src/app.js
174
src/app.js
@@ -1,6 +1,7 @@
|
||||
import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js";
|
||||
|
||||
import { renderSettings } from "./settings.js";
|
||||
import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "./race_audio.js";
|
||||
|
||||
import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js";
|
||||
|
||||
@@ -128,7 +129,7 @@ const saveAmmcConfigToBackend = (config) => saveAmmcConfigToBackendHelper(config
|
||||
const refreshAmmcStatus = () => refreshAmmcStatusHelper({ ammc, t, getBackendUrl });
|
||||
const startManagedAmmc = () => startManagedAmmcHelper({ ammc, t, getBackendUrl });
|
||||
const stopManagedAmmc = () => stopManagedAmmcHelper({ ammc, t, getBackendUrl });
|
||||
const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, applyTheme });
|
||||
const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings, applyTheme });
|
||||
const hydrateFromBackend = () => hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState });
|
||||
const scheduleBackendSync = () => scheduleBackendSyncHelper({ getBackendSyncTimer: () => backendSyncTimer, setBackendSyncTimer: (value) => { backendSyncTimer = value; }, syncStateToBackend });
|
||||
const syncStateToBackend = () => syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion: () => localStateVersion, getSyncedStateVersion: () => syncedStateVersion, setSyncedStateVersion: (value) => { syncedStateVersion = value; }, getLocalStateDirty: () => localStateDirty, setLocalStateDirty: (value) => { localStateDirty = value; } });
|
||||
@@ -147,10 +148,36 @@ const playBestLapCue = () => playBestLapCueHelper({ ensureAudioContext });
|
||||
const pushOverlayEvent = (type, label, options = {}) => pushOverlayEventHelper(type, label, { uid, state, t, getOverlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, getOverlayMode: () => overlayMode, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue }, options);
|
||||
const getSpeechVoices = () => getSpeechVoicesHelper();
|
||||
const speakText = (text, options = {}) => speakTextHelper(text, { state, currentLanguage }, options);
|
||||
const announcePassing = (entry) => announcePassingHelper(entry, { state, t, playPassingBeep, speakText });
|
||||
const triggerRaceAudio = (eventKey, options = {}) => raceAudioEngine.trigger(eventKey, options);
|
||||
const getPassingSoundProfile = (entry) => {
|
||||
const car = state.cars.find((item) => item.id === entry?.carId);
|
||||
const driver = state.drivers.find((item) => item.id === entry?.driverId);
|
||||
return String(car?.passSound || driver?.passSound || "").trim();
|
||||
};
|
||||
const announcePassing = (entry) => {
|
||||
if (state.settings.raceAudio?.enabled) {
|
||||
triggerRaceAudio("transponder_pass", { variant: entry?.carId || entry?.driverId || entry?.transponder, effectPath: getPassingSoundProfile(entry), scope: entry?.key });
|
||||
return;
|
||||
}
|
||||
announcePassingHelper(entry, { state, t, playPassingBeep, speakText });
|
||||
};
|
||||
const announceRaceStarted = (session) => announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText });
|
||||
const announceRaceFinished = () => announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText });
|
||||
const handleSessionTimerTick = () => handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, announceRaceFinished });
|
||||
const announceRaceFinished = () => {
|
||||
const session = getActiveSession();
|
||||
announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText });
|
||||
if (!session || !state.settings.raceAudio?.enabled) return;
|
||||
triggerRaceAudio(session.type === "qualification" ? "qualifying_finished" : "race_finished", { interrupt: true, logData: { raceId: session.eventId, sessionId: session.id } });
|
||||
const podium = buildLeaderboard(session).slice(0, 3);
|
||||
if (podium[0]) triggerRaceAudio(session.type === "qualification" ? "pole_position" : session.type === "heat" ? "heat_winner" : "race_winner", { variables: { driverName: podium[0].displayName || podium[0].driverName }, logData: { raceId: session.eventId, driverId: podium[0].driverId } });
|
||||
if (podium.length > 1) triggerRaceAudio("podium", { variables: { podium: podium.map((row) => row.displayName || row.driverName).join(", ") } });
|
||||
};
|
||||
const announceRaceClockEvent = (eventKey, session) => triggerRaceAudio(eventKey, { logData: { raceId: session.eventId, sessionId: session.id } });
|
||||
const handleSessionTimerTick = () => handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, announceRaceFinished, announceRaceClockEvent });
|
||||
const cancelRaceStart = () => raceAudioEngine.cancelStartSequence();
|
||||
const startSessionWithAudio = (session, callbacks) => {
|
||||
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || ""));
|
||||
return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice });
|
||||
};
|
||||
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, renderSessionChannelAnalysis });
|
||||
const connectDecoder = () => connectDecoderHelper({
|
||||
@@ -177,6 +204,13 @@ const disconnectDecoder = (options = {}) => disconnectDecoderHelper(options, {
|
||||
setReconnectTimer: (role, value) => { reconnectTimers[role] = value; },
|
||||
updateConnectionBadge,
|
||||
});
|
||||
const getTrackRecordMs = (session) => {
|
||||
const event = state.events.find((item) => item.id === session?.eventId);
|
||||
if (!event?.classId) return null;
|
||||
const candidates = state.sessions.filter((item) => item.id !== session?.id && state.events.find((race) => race.id === item.eventId)?.classId === event?.classId);
|
||||
const values = candidates.flatMap((item) => Object.values(state.resultsBySession[item.id]?.competitors || {}).map((row) => Number(row.bestLapMs || 0))).filter((value) => value > 500);
|
||||
return values.length ? Math.min(...values) : null;
|
||||
};
|
||||
const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessageHelper(msg, {
|
||||
state,
|
||||
t,
|
||||
@@ -194,6 +228,8 @@ const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessa
|
||||
lastOverlayLeaderKeyBySession,
|
||||
lastOverlayTop3BySession,
|
||||
lastOverlayBestLapByKey,
|
||||
triggerRaceAudio,
|
||||
getTrackRecordMs,
|
||||
}, decoderRole);
|
||||
const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml });
|
||||
const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell });
|
||||
@@ -201,7 +237,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, renderSessionChannelAnalysis, 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 renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), 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 = () => {
|
||||
@@ -214,7 +250,13 @@ const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(
|
||||
const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats);
|
||||
const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats);
|
||||
const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } });
|
||||
const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState });
|
||||
const applyCompetitorCorrection = (session, row, options = {}) => {
|
||||
const result = applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState });
|
||||
if (!options.reset && ((Number(options.timeMsDelta || 0) > 0) || (Number(options.lapDelta || 0) < 0))) {
|
||||
triggerRaceAudio("penalty", { variables: { driverName: row?.displayName || row?.driverName || "" }, driverId: row?.driverId, logData: { raceId: session?.eventId, driverId: row?.driverId } });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing });
|
||||
const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState });
|
||||
const restoreCompetitorLastInvalidLap = (session, row) => restoreCompetitorLastInvalidLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, recalculateCompetitorFromPassings, uid, t, formatLap, saveState });
|
||||
@@ -966,6 +1008,34 @@ const TRANSLATIONS = {
|
||||
"settings.bundled_hint": "Bundlad standardbana i appen används automatiskt om den finns.",
|
||||
"settings.use_server_ws": "Använd serverns WS-url",
|
||||
"settings.audio": "Ljud",
|
||||
"common.off": "Av",
|
||||
"audio.race_sounds": "Raceljud och utrop",
|
||||
"audio.enabled": "Aktivera nya raceljud",
|
||||
"audio.mute": "Tysta allt",
|
||||
"audio.language": "Utropsspråk",
|
||||
"audio.start_countdown": "Nedräkning före start",
|
||||
"audio.master_volume": "Mastervolym",
|
||||
"audio.effects_volume": "Effektvolym",
|
||||
"audio.voice_volume": "Röstvolym",
|
||||
"audio.passing_volume": "Passing-volym",
|
||||
"audio.start_lights": "Startljus",
|
||||
"audio.random_delay": "Slumpad startfördröjning",
|
||||
"audio.min_delay": "Minsta fördröjning (sek)",
|
||||
"audio.max_delay": "Största fördröjning (sek)",
|
||||
"audio.per_competitor": "Olika passing-ton per förare/bil",
|
||||
"audio.test": "Testa",
|
||||
"audio.note": "Effekter får överlappa och blockerar aldrig tidtagningen. Kritiska utrop avbryter lägre prioriterat tal. Saknade ljudfiler använder fallback eller hoppas över.",
|
||||
"audio.effect": "Effekt",
|
||||
"audio.voice": "Röst",
|
||||
"audio.track_call": "STOPP PÅ BANAN",
|
||||
"audio.track_clear": "Banan fri",
|
||||
"audio.red_flag": "Röd flagg",
|
||||
"audio.race_pause": "Pausa race",
|
||||
"audio.race_resume": "Fortsätt race",
|
||||
"audio.false_start": "Felaktig start",
|
||||
"audio.marshal_warning": "Banvaktsvarning",
|
||||
"audio.passing_sound": "Eget passing-ljud",
|
||||
"audio.pass_default": "Standard/fallback",
|
||||
"settings.audio_enabled": "Aktivera ljud i browsern",
|
||||
"settings.speaker_passing_cue": "Speaker-cue vid passing",
|
||||
"settings.speaker_leader_cue": "Speaker-cue vid ny ledare",
|
||||
@@ -1097,6 +1167,7 @@ const TRANSLATIONS = {
|
||||
"common.reset": "Återställ",
|
||||
"status.ready": "redo",
|
||||
"status.running": "pågår",
|
||||
"status.paused": "pausad",
|
||||
"status.finished": "klar",
|
||||
"status.leader": "LEDARE",
|
||||
"status.seeded": "SEED",
|
||||
@@ -1135,12 +1206,15 @@ 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.audio_title": "Raceljud, startljus och race control",
|
||||
"guide.audio_1": "1. Öppna Inställningar -> Raceljud och utrop. Välj svenska eller engelska och ställ master-, effekt-, röst- och passing-volym.",
|
||||
"guide.audio_2": "2. Aktivera effekt och röst separat för varje event. Testknappen på raden provar exakt det eventet; saknade WAV-filer hoppas över eller använder passing-fallback.",
|
||||
"guide.audio_3": "3. Välj 0, 10, 30 eller 60 sekunders förstart. För race följer sedan Förare gör er redo, startfras, fem startljus, slumpad väntan och startton.",
|
||||
"guide.audio_4": "4. Den officiella raceklockan startar i samma callback som starttonen. Open/Free Practice startar direkt utan race-sekvens.",
|
||||
"guide.audio_5": "5. Under Tidtagning finns manuella knappar för Stopp på banan, Banan fri, Röd flagg, felaktig start och banvaktsvarning. Kritiska utrop avbryter lägre prioriterat tal.",
|
||||
"guide.audio_6": "6. Under Förare och Bilar kan du välja pass_01.wav till pass_08.wav. Om filen saknas används standardljudet; lägg egna korta filer i rc-race-sound-pack/sounds/effects/.",
|
||||
"guide.audio_7": "7. Personbästa, snabbaste varv, banrekord, ledarbyte, tidsvarningar, depå in/ut, finish och podium hämtas från befintliga timinghändelser.",
|
||||
"guide.audio_8": "8. Ljudet spelas i operatörens webbläsare. Klicka Testa före racet, kontrollera ljudutgången och håll Tidtagning-fliken öppen.",
|
||||
"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.",
|
||||
@@ -2041,6 +2115,34 @@ const TRANSLATIONS = {
|
||||
"settings.bundled_hint": "The bundled app path is used automatically when present.",
|
||||
"settings.use_server_ws": "Use server WS URL",
|
||||
"settings.audio": "Audio",
|
||||
"common.off": "Off",
|
||||
"audio.race_sounds": "Race sounds and announcements",
|
||||
"audio.enabled": "Enable new race audio",
|
||||
"audio.mute": "Mute all",
|
||||
"audio.language": "Announcement language",
|
||||
"audio.start_countdown": "Pre-start countdown",
|
||||
"audio.master_volume": "Master volume",
|
||||
"audio.effects_volume": "Effects volume",
|
||||
"audio.voice_volume": "Voice volume",
|
||||
"audio.passing_volume": "Passing volume",
|
||||
"audio.start_lights": "Start lights",
|
||||
"audio.random_delay": "Random start delay",
|
||||
"audio.min_delay": "Minimum delay (seconds)",
|
||||
"audio.max_delay": "Maximum delay (seconds)",
|
||||
"audio.per_competitor": "Different passing tone per driver/car",
|
||||
"audio.test": "Test",
|
||||
"audio.note": "Effects may overlap and never block timing. Critical announcements interrupt lower-priority speech. Missing files use a fallback or are skipped.",
|
||||
"audio.effect": "Effect",
|
||||
"audio.voice": "Voice",
|
||||
"audio.track_call": "TRACK CALL",
|
||||
"audio.track_clear": "Track clear",
|
||||
"audio.red_flag": "Red flag",
|
||||
"audio.race_pause": "Pause race",
|
||||
"audio.race_resume": "Resume race",
|
||||
"audio.false_start": "False start",
|
||||
"audio.marshal_warning": "Marshal warning",
|
||||
"audio.passing_sound": "Custom passing sound",
|
||||
"audio.pass_default": "Default/fallback",
|
||||
"settings.audio_enabled": "Enable browser audio",
|
||||
"settings.speaker_passing_cue": "Speaker cue on passing",
|
||||
"settings.speaker_leader_cue": "Speaker cue on new leader",
|
||||
@@ -2172,6 +2274,7 @@ const TRANSLATIONS = {
|
||||
"common.reset": "Reset",
|
||||
"status.ready": "ready",
|
||||
"status.running": "running",
|
||||
"status.paused": "paused",
|
||||
"status.finished": "finished",
|
||||
"status.leader": "LEADER",
|
||||
"status.seeded": "SEED",
|
||||
@@ -2210,12 +2313,15 @@ 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.audio_title": "Race audio, start lights, and race control",
|
||||
"guide.audio_1": "1. Open Settings -> Race sounds and announcements. Select Swedish or English and set master, effects, voice, and passing volume.",
|
||||
"guide.audio_2": "2. Enable effect and voice separately for every event. Each row has an exact event test; missing WAV files are skipped or use the passing fallback.",
|
||||
"guide.audio_3": "3. Select a 0, 10, 30, or 60 second pre-start. A race then runs drivers stand by, the start phrase, five start lights, random wait, and start tone.",
|
||||
"guide.audio_4": "4. The official race clock starts in the same callback as the start tone. Open/Free Practice starts immediately without the race sequence.",
|
||||
"guide.audio_5": "5. Timing provides manual controls for Track call, Track clear, Red flag, False start, and Marshal warning. Critical announcements interrupt lower-priority speech.",
|
||||
"guide.audio_6": "6. Drivers and Cars can select pass_01.wav through pass_08.wav. A missing file uses the default; add short custom files under rc-race-sound-pack/sounds/effects/.",
|
||||
"guide.audio_7": "7. Personal best, fastest lap, track record, leader changes, time warnings, pit entry/exit, finish, and podium reuse existing timing events.",
|
||||
"guide.audio_8": "8. Audio plays in the operator browser. Use Test before racing, verify the audio output, and keep the Timing tab open.",
|
||||
"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.",
|
||||
@@ -2507,6 +2613,7 @@ function normalizeDriver(driver = {}) {
|
||||
profileId: String(driver?.profileId || "").trim(),
|
||||
club: String(driver?.club || "").trim(),
|
||||
country: String(driver?.country || "").trim(),
|
||||
passSound: String(driver?.passSound || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2516,6 +2623,7 @@ function normalizeCar(car = {}) {
|
||||
name: String(car?.name || "").trim(),
|
||||
transponder: String(car?.transponder || "").trim(),
|
||||
brand: String(car?.brand || "").trim(),
|
||||
passSound: String(car?.passSound || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2523,7 +2631,7 @@ function normalizeSession(session = {}) {
|
||||
const type = SESSION_TYPES.includes(String(session?.type || "").toLowerCase())
|
||||
? String(session.type).toLowerCase()
|
||||
: "practice";
|
||||
const status = ["ready", "running", "stopped", "finished"].includes(String(session?.status || "").toLowerCase())
|
||||
const status = ["ready", "running", "paused", "stopped", "finished"].includes(String(session?.status || "").toLowerCase())
|
||||
? String(session.status).toLowerCase()
|
||||
: "ready";
|
||||
const seedMethod = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase())
|
||||
@@ -2556,6 +2664,10 @@ function normalizeSession(session = {}) {
|
||||
startedAt: Number.isFinite(Number(session?.startedAt)) ? Number(session.startedAt) : null,
|
||||
endedAt: Number.isFinite(Number(session?.endedAt)) ? Number(session.endedAt) : null,
|
||||
finishedByTimer: session?.finishedByTimer === true,
|
||||
pausedAt: Number.isFinite(Number(session?.pausedAt)) ? Number(session.pausedAt) : null,
|
||||
totalPausedMs: Math.max(0, Number(session?.totalPausedMs || 0) || 0),
|
||||
followUpPausedMs: Math.max(0, Number(session?.followUpPausedMs || 0) || 0),
|
||||
audioAnnouncements: Array.isArray(session?.audioAnnouncements) ? session.audioAnnouncements.map(String).filter(Boolean) : [],
|
||||
assignments,
|
||||
driverIds: Array.isArray(session?.driverIds) ? session.driverIds.map(String).filter(Boolean) : [],
|
||||
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds.map(String).filter(Boolean) : [],
|
||||
@@ -2627,6 +2739,21 @@ const backend = {
|
||||
};
|
||||
let audioCtx = null;
|
||||
let lastFinishAnnouncementSessionId = null;
|
||||
const raceAudioEngine = createRaceAudioEngine({
|
||||
getSettings: () => state.settings.raceAudio,
|
||||
speak: (text, options = {}) => new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (value) => { if (!settled) { settled = true; resolve(value); } };
|
||||
const started = speakText(text, { ...options, onEnd: () => done(true), onError: () => done(false) });
|
||||
if (!started) done(false);
|
||||
else setTimeout(() => done(true), 15000);
|
||||
}),
|
||||
log: (entry) => {
|
||||
if (!Array.isArray(state.audioEventLog)) state.audioEventLog = [];
|
||||
state.audioEventLog.unshift(entry);
|
||||
if (state.audioEventLog.length > 500) state.audioEventLog.length = 500;
|
||||
},
|
||||
});
|
||||
const ammc = {
|
||||
config: createDefaultAmmcConfig(),
|
||||
status: null,
|
||||
@@ -2792,6 +2919,7 @@ function seedDefaultData() {
|
||||
}
|
||||
|
||||
state.settings.obsOverlay = normalizeObsOverlaySettings(state.settings.obsOverlay);
|
||||
state.settings.raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
|
||||
|
||||
state.drivers = state.drivers.map((driver) => normalizeDriver(driver)).filter((driver) => driver.name);
|
||||
state.cars = state.cars.map((car) => normalizeCar(car)).filter((car) => car.name);
|
||||
@@ -2814,6 +2942,7 @@ function loadState() {
|
||||
events: parsed.events || [],
|
||||
sessions: parsed.sessions || [],
|
||||
resultsBySession: parsed.resultsBySession || {},
|
||||
audioEventLog: Array.isArray(parsed.audioEventLog) ? parsed.audioEventLog.slice(0, 500) : [],
|
||||
training: normalizeTrainingState(parsed.training),
|
||||
activeSessionId: parsed.activeSessionId || null,
|
||||
settings: {
|
||||
@@ -2850,6 +2979,7 @@ function loadState() {
|
||||
? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
|
||||
: [],
|
||||
obsOverlay: normalizeObsOverlaySettings(parsed.settings?.obsOverlay),
|
||||
raceAudio: normalizeRaceAudioSettings(parsed.settings?.raceAudio),
|
||||
},
|
||||
decoder: {
|
||||
connected: false,
|
||||
@@ -2878,6 +3008,7 @@ function loadState() {
|
||||
events: [],
|
||||
sessions: [],
|
||||
resultsBySession: {},
|
||||
audioEventLog: [],
|
||||
training: normalizeTrainingState(),
|
||||
activeSessionId: null,
|
||||
settings: {
|
||||
@@ -2912,6 +3043,7 @@ function loadState() {
|
||||
logoDataUrl: "",
|
||||
racePresets: [],
|
||||
obsOverlay: normalizeObsOverlaySettings(),
|
||||
raceAudio: normalizeRaceAudioSettings(),
|
||||
},
|
||||
decoder: {
|
||||
connected: false,
|
||||
@@ -3019,6 +3151,7 @@ function buildPersistableState() {
|
||||
events: state.events,
|
||||
sessions: state.sessions.map((session) => normalizeSession(session)),
|
||||
resultsBySession: state.resultsBySession,
|
||||
audioEventLog: Array.isArray(state.audioEventLog) ? state.audioEventLog.slice(0, 500) : [],
|
||||
training: normalizeTrainingState(state.training),
|
||||
activeSessionId: state.activeSessionId,
|
||||
settings: state.settings,
|
||||
@@ -3498,6 +3631,9 @@ function renderView() {
|
||||
refreshAmmcStatus,
|
||||
startManagedAmmc,
|
||||
stopManagedAmmc,
|
||||
normalizeRaceAudioSettings,
|
||||
raceAudioEventDefinitions: RACE_AUDIO_EVENT_DEFINITIONS,
|
||||
triggerRaceAudio,
|
||||
});
|
||||
break;
|
||||
case "guide":
|
||||
|
||||
Reference in New Issue
Block a user