diff --git a/README.md b/README.md index 9806635..68c9cf9 100644 --- a/README.md +++ b/README.md @@ -534,3 +534,17 @@ Example message: "loop_id": "55" } ``` + +## Race audio and start lights + +The repository includes `rc-race-sound-pack/`. `sound_manifest.json` is the source of truth for event-to-WAV and Swedish/English announcement mappings. The server exposes it at `/race-sounds/sound_manifest.json`; WAV files are served from `/race-sounds/sounds/effects/`. + +1. Open `Settings -> Race sounds and announcements`. +2. Select `sv` or `en`, then set master, effects, voice, and passing volume. +3. Enable or disable effect and voice independently for each event and use its Test button. +4. Select a 0/10/30/60-second pre-start, start lights, and random delay limits. For timed race sessions the clock starts at the same callback that initiates the start tone. Open/Free Practice starts immediately. +5. Use the Race Control buttons in `Timing` for Track call, Track clear, Red flag, False start, and Marshal warning. + +Effects are asynchronous and never awaited by decoder processing. Critical speech interrupts lower-priority speech; short passing effects may overlap. Missing WAV files are ignored safely. A driver or car may select `pass_01.wav` through `pass_08.wav`; until those optional files exist under `rc-race-sound-pack/sounds/effects/`, playback falls back to `transponder_pass.wav`. + +Implemented automatic hooks include passings, personal/meeting best, fastest lap, historical class track record, leader and position changes, pit entry/exit, 60/30/10 seconds remaining, final lap/time expired, finish, winner, and podium. Penalty announcements reuse the existing judging correction action. diff --git a/README.sv.md b/README.sv.md index dda93e0..c67779c 100644 --- a/README.sv.md +++ b/README.sv.md @@ -523,3 +523,17 @@ Exempelmeddelande: "loop_id": "55" } ``` + +## Raceljud och startljus + +Repot innehåller `rc-race-sound-pack/`. `sound_manifest.json` är källan för kopplingen mellan event, WAV-filer och svenska/engelska utrop. Servern publicerar manifestet på `/race-sounds/sound_manifest.json` och WAV-filerna under `/race-sounds/sounds/effects/`. + +1. Öppna `Inställningar -> Raceljud och utrop`. +2. Välj `sv` eller `en` och ställ master-, effekt-, röst- och passing-volym. +3. Slå på/av effekt och röst separat för varje event och använd radens Testa-knapp. +4. Välj 0/10/30/60 sekunders förstart, startljus och gränser för slumpad startfördröjning. För tidsstyrda race startar klockan i samma callback som starttonen initieras. Open/Free Practice startar direkt. +5. Använd Race Control-knapparna i `Tidtagning` för Stopp på banan, Banan fri, Röd flagg, Felaktig start och Banvaktsvarning. + +Effekter körs asynkront och väntas aldrig in av decoderflödet. Kritiska utrop avbryter lägre prioriterat tal; korta passing-ljud får överlappa. Saknade WAV-filer ignoreras säkert. En förare eller bil kan välja `pass_01.wav` till `pass_08.wav`; tills dessa valfria filer finns i `rc-race-sound-pack/sounds/effects/` används `transponder_pass.wav` som fallback. + +Automatiska hooks omfattar passering, person-/meetingbästa, snabbaste varv, historiskt klass-banrekord, ledar- och positionsbyte, depå in/ut, 60/30/10 sekunder kvar, sista varv/tiden slut, finish, vinnare och podium. Penalty-utrop återanvänder domarvyns befintliga korrigeringsåtgärd. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 0ab1e59..6395526 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2393,3 +2393,103 @@ If the project grows further, the next useful additions to this document are: - SQLite table contract section - controller-to-view ID map for critical forms/buttons - browser-level smoke tests for critical create/edit workflows + +## 20. Race Audio Architecture + +### 20.1 Ownership and dependency direction + +`src/race_audio.js` owns audio scheduling only. It does not own session state, laps, validation, leaderboards, penalties, pit timing, or finish rules. + +Dependency flow: + +```text +AMMC WebSocket + -> decoder_runtime.processDecoderMessageHelper + -> existing result/leaderboard mutation + -> triggerRaceAudio(event, metadata) + -> race_audio.createRaceAudioEngine + -> short HTMLAudio effect + prioritized Web Speech queue + +Timing Start button + -> timing_views validation + -> raceAudioEngine.runStartSequence + -> start-light state callbacks + -> start tone is initiated + -> existing commitStart callback sets session.startedAt/status + +1-second application clock + -> runtime_services.handleSessionTimerTickHelper + -> existing remaining/follow-up/finish rules + -> one-shot audio markers on the normalized session + -> triggerRaceAudio +``` + +The service is constructed in `src/app.js`. `src/app.js` is the composition root and injects `triggerRaceAudio`, `startSessionWithAudio`, historical-record lookup, speech, settings, and logging callbacks into the extracted modules. + +### 20.2 Files and contracts + +- `src/race_audio.js`: event catalog, settings normalization, manifest loading, safe effect playback, fallback, priority/cooldown queue, templates, start-light state machine, start sequence, status. +- `rc-race-sound-pack/sound_manifest.json`: event-to-effect mapping, `sv`/`en` templates, runtime defaults, optional passing pool names. +- `server.js`: read-only `/race-sounds` static route. JSON is `no-store`; WAV files may be cached for one day. +- `src/decoder_runtime.js`: emits audio metadata only after existing validation/result mutation. Split timing never emits a lap; pit roles emit `entering_pits`/`leaving_pits` without adding laps. +- `src/runtime_services.js`: legacy audio fallback, Web Speech adapter, persisted-state hydration, and one-shot 60/30/10/time-expired/final-lap markers. +- `src/timing_views.js`: visual start lights and manual race-control cues. It retains the authoritative client-side start callback. +- `src/settings.js`: persistent volume/language/start configuration and per-event effect/voice matrix. +- `src/core_views.js`: optional `passSound` on driver and car records. + +### 20.3 Persisted schema + +`state.settings.raceAudio` is normalized on local load, backend hydration, seed, import/export, and save: + +```js +{ + enabled, muted, language, + masterVolume, effectsVolume, voiceVolume, passingVolume, + startCountdownSeconds, + startLights, lightIntervalMs, + randomStartDelay, minStartDelay, maxStartDelay, + perCompetitorPassingTone, + events: { [eventKey]: { effect, voice } }, + templates: { [eventKey]: string } +} +``` + +Drivers and cars optionally persist `passSound`. Sessions persist `audioAnnouncements`, a small list of one-shot clock markers reset at every start. Audio log entries are persisted in `state.audioEventLog` and bounded to 500 entries. Decoder passings reuse the normal state synchronization path, so audio logging never adds a blocking write to transponder processing; timing persistence remains the source of truth. + +### 20.4 Priority, concurrency, and failure behavior + +- `critical`: track call, red/yellow flag, false start, timing failure, pause. +- `high`: start, finish, penalty, marshal, final lap. +- `normal`: fastest lap, track record, leader, countdown, results. +- `low`: personal best and position changes. +- `pass`: short transponder effects. + +Effects use independent `Audio` instances and may overlap. They never hold decoder processing. Voice is serialized by priority. A critical voice can cancel the current lower-priority browser utterance. Cooldowns are scoped by event and competitor where available. + +All fetch, JSON, file, `Audio.play()`, speech, subscriber, and log failures are caught. A custom missing passing sound retries `effects/transponder_pass.wav`. Failure never mutates or rejects the timing path. + +### 20.5 Start timing invariant + +Start-light state is independent from sound and exposes: + +```text +IDLE -> GRID -> READY -> LIGHT_1 -> LIGHT_2 -> LIGHT_3 -> LIGHT_4 -> LIGHT_5 -> WAIT_RANDOM -> GO +``` + +The random value is generated once inside the race-controller service, not by the visual light component. After the delay, the engine initiates the start tone and immediately invokes the existing `commitStart` callback. That callback sets `session.status = running` and `session.startedAt = Date.now()`. No HTTP request occurs between tone initiation and timestamp assignment. + +Practice types intentionally bypass this sequence. This preserves continuously running Open Practice behavior. + +### 20.6 Event mapping and calculations + +Automatic decoder events are emitted only for valid main-loop laps. Personal best requires an earlier personal best. Fastest lap requires an earlier session fastest lap. Track record requires an earlier best lap from another session in the same class. Leader/position events compare the existing leaderboard immediately before and after the valid passing. These checks observe timing results; they do not recalculate standings independently. + +Finish announcements run only after the existing follow-up/finish logic marks the session finished. Winner and podium use `buildLeaderboard(session)` after that transition. Positive time penalties and removed laps emit the penalty event after the existing judging function saves its correction. + +### 20.7 Known boundary + +The application currently has no authentication or role model. Race-control sound buttons therefore inherit the same local operator access as all Timing and Judging controls. Do not expose the administrative application publicly; expose only the existing public overlay endpoints. A future authorization layer should gate all race-control actions together rather than adding audio-only permissions. + +### 20.8 Tests + +`tests/race_audio.test.js` covers normalization, language/event mapping, custom-file fallback, critical interruption, start-light order, random delay, and tone/timestamp callback order. Existing decoder, runtime persistence, audio fallback, race timing, and server integration suites remain the regression boundary. diff --git a/rc-race-sound-pack/sound_manifest.json b/rc-race-sound-pack/sound_manifest.json index d219d43..8aa9c70 100644 --- a/rc-race-sound-pack/sound_manifest.json +++ b/rc-race-sound-pack/sound_manifest.json @@ -1,66 +1,91 @@ { - "format": { - "sample_rate": 48000, - "channels": 1, - "pcm": "16-bit", - "recommended_runtime_gain_db": -6 - }, + "format": { "sample_rate": 48000, "channels": 1, "pcm": "16-bit", "recommended_runtime_gain_db": -6 }, "effects": [ - "fastest_lap_trophy.wav", - "final_lap.wav", - "personal_best.wav", - "position_gain.wav", - "position_loss.wav", - "race_finish.wav", - "start_beep.wav", - "track_record_fanfare.wav", - "transponder_pass.wav", - "warning.wav" + "fastest_lap_trophy.wav", "final_lap.wav", "personal_best.wav", "position_gain.wav", "position_loss.wav", + "race_finish.wav", "start_beep.wav", "track_record_fanfare.wav", "transponder_pass.wav", "warning.wav" ], + "events": { + "start_tone": { "effect": "effects/start_beep.wav" }, + "transponder_pass": { "effect": "effects/transponder_pass.wav" }, + "transponder_detected": { "effect": "effects/transponder_pass.wav" }, + "entering_pits": { "effect": "effects/transponder_pass.wav" }, + "leaving_pits": { "effect": "effects/transponder_pass.wav" }, + "personal_best": { "effect": "effects/personal_best.wav" }, + "fastest_lap": { "effect": "effects/fastest_lap_trophy.wav" }, + "track_record": { "effect": "effects/track_record_fanfare.wav" }, + "position_gain": { "effect": "effects/position_gain.wav" }, + "position_loss": { "effect": "effects/position_loss.wav" }, + "final_lap": { "effect": "effects/final_lap.wav" }, + "race_finished": { "effect": "effects/race_finish.wav" }, + "qualifying_finished": { "effect": "effects/race_finish.wav" }, + "race_winner": { "effect": "effects/fastest_lap_trophy.wav" }, + "heat_winner": { "effect": "effects/fastest_lap_trophy.wav" }, + "overall_winner": { "effect": "effects/fastest_lap_trophy.wav" }, + "race_10_seconds": { "effect": "effects/warning.wav" }, + "ten_seconds_remaining": { "effect": "effects/warning.wav" }, + "time_expired": { "effect": "effects/warning.wav" }, + "track_call": { "effect": "effects/warning.wav" }, + "yellow_flag": { "effect": "effects/warning.wav" }, + "red_flag": { "effect": "effects/warning.wav" }, + "false_start": { "effect": "effects/warning.wav" }, + "race_paused": { "effect": "effects/warning.wav" }, + "marshal_warning": { "effect": "effects/warning.wav" }, + "marshal_required": { "effect": "effects/warning.wav" }, + "penalty": { "effect": "effects/warning.wav" }, + "stop_and_go": { "effect": "effects/warning.wav" }, + "drive_through": { "effect": "effects/warning.wav" }, + "disqualified": { "effect": "effects/warning.wav" }, + "pit_speed_warning": { "effect": "effects/warning.wav" }, + "timing_disconnected": { "effect": "effects/warning.wav" }, + "unknown_transponder": { "effect": "effects/warning.wav" }, + "audio_test": { "effect": "effects/start_beep.wav" } + }, "voice_events": { "en": { - "race_1_minute": "Race starts in one minute.", - "race_30_seconds": "Thirty seconds to start.", - "drivers_ready": "Drivers, stand by.", - "start_phrase": "Starting on the sound of the tone.", - "personal_best": "Personal best!", - "fastest_lap": "Fastest lap!", - "track_record": "New track record!", - "new_leader": "We have a new race leader.", - "one_minute_remaining": "One minute remaining.", - "thirty_seconds_remaining": "Thirty seconds remaining.", - "final_lap": "Final lap.", - "time_expired": "Time has expired. Finish the lap you are on.", - "race_finished": "Race is finished.", - "race_winner": "Race winner!" + "grid_open": "The starting grid is open.", "grid_closed": "The starting grid is closed.", + "race_1_minute": "Race starts in one minute.", "race_30_seconds": "Thirty seconds to start.", "race_10_seconds": "Ten seconds to start.", + "drivers_ready": "Drivers, stand by.", "start_sequence": "Starting on the sound of the tone.", "false_start": "False start.", "restart_race": "Prepare to restart the race.", + "personal_best": "Personal best. {driverName}. {lapTime}.", "fastest_lap": "Fastest lap. {driverName}. {lapTime}.", + "track_record": "New track record. {driverName}. {lapTime}.", "new_leader": "New race leader. {driverName}.", + "final_lap": "Final lap.", "one_minute_remaining": "One minute remaining.", "thirty_seconds_remaining": "Thirty seconds remaining.", + "ten_seconds_remaining": "Ten seconds remaining.", "time_expired": "Time has expired. Finish the lap you are on.", "race_finished": "Race is finished.", + "track_call": "Track call. Track call.", "track_clear": "Track is clear.", "yellow_flag": "Yellow flag.", "red_flag": "Red flag. Stop all cars.", + "race_paused": "Race paused.", "race_resumed": "Race resumed.", "marshal_warning": "Marshal warning.", + "marshal_required": "Marshal required at post {marshalPost}.", "penalty": "Penalty for {driverName}.", + "stop_and_go": "Stop and go penalty for {driverName}.", "drive_through": "Drive through penalty for {driverName}.", "disqualified": "{driverName} is disqualified.", + "pit_lane_open": "Pit lane is open.", "pit_lane_closed": "Pit lane is closed.", "pit_speed_warning": "Pit lane speed warning for {driverName}.", + "qualifying_start": "Qualifying is starting.", "qualifying_remaining": "Qualifying time remaining: {time}.", "qualifying_finished": "Qualifying is finished.", + "provisional_pole": "Provisional pole. {driverName}.", "pole_position": "Pole position. {driverName}.", + "race_winner": "Race winner. {driverName}.", "second_place": "Second place. {driverName}.", "third_place": "Third place. {driverName}.", + "podium": "Podium: {podium}.", "heat_winner": "Heat winner. {driverName}.", "overall_winner": "Overall winner. {driverName}.", + "timing_connected": "Timing system connected.", "timing_disconnected": "Timing system disconnected.", + "unknown_transponder": "Unknown transponder {transponder}.", "audio_test": "Race audio test." }, "sv": { - "race_1_minute": "Start om en minut.", - "race_30_seconds": "Trettio sekunder till start.", - "drivers_ready": "Förare, gör er redo.", - "start_phrase": "Start på signalen.", - "personal_best": "Personbästa!", - "fastest_lap": "Snabbaste varv!", - "track_record": "Nytt banrekord!", - "new_leader": "Vi har en ny ledare.", - "one_minute_remaining": "En minut kvar.", - "thirty_seconds_remaining": "Trettio sekunder kvar.", - "final_lap": "Sista varvet.", - "time_expired": "Tiden är slut. Slutför pågående varv.", - "race_finished": "Racet är avslutat.", - "race_winner": "Segrare!" + "grid_open": "Startfältet är öppet.", "grid_closed": "Startfältet är stängt.", + "race_1_minute": "Start om en minut.", "race_30_seconds": "Trettio sekunder till start.", "race_10_seconds": "Tio sekunder till start.", + "drivers_ready": "Förare, gör er redo.", "start_sequence": "Start på signalen.", "false_start": "Felaktig start.", "restart_race": "Förbered omstart.", + "personal_best": "Personbästa. {driverName}. {lapTime}.", "fastest_lap": "Snabbaste varv. {driverName}. {lapTime}.", + "track_record": "Nytt banrekord. {driverName}. {lapTime}.", "new_leader": "Ny ledare. {driverName}.", + "final_lap": "Sista varvet.", "one_minute_remaining": "En minut kvar.", "thirty_seconds_remaining": "Trettio sekunder kvar.", + "ten_seconds_remaining": "Tio sekunder kvar.", "time_expired": "Tiden är slut. Slutför pågående varv.", "race_finished": "Racet är avslutat.", + "track_call": "Stopp på banan. Stopp på banan.", "track_clear": "Banan är fri.", "yellow_flag": "Gul flagg.", "red_flag": "Röd flagg. Stoppa alla bilar.", + "race_paused": "Racet är pausat.", "race_resumed": "Racet fortsätter.", "marshal_warning": "Varning till banvakt.", + "marshal_required": "Banvakt behövs vid post {marshalPost}.", "penalty": "Bestraffning för {driverName}.", + "stop_and_go": "Stop and go för {driverName}.", "drive_through": "Drive through för {driverName}.", "disqualified": "{driverName} är diskvalificerad.", + "pit_lane_open": "Depån är öppen.", "pit_lane_closed": "Depån är stängd.", "pit_speed_warning": "Varning för hög fart i depån för {driverName}.", + "qualifying_start": "Kvalet startar.", "qualifying_remaining": "Kvaltid kvar: {time}.", "qualifying_finished": "Kvalet är avslutat.", + "provisional_pole": "Preliminär pole. {driverName}.", "pole_position": "Pole position. {driverName}.", + "race_winner": "Segrare. {driverName}.", "second_place": "Andra plats. {driverName}.", "third_place": "Tredje plats. {driverName}.", + "podium": "Prispall: {podium}.", "heat_winner": "Heatsegrare. {driverName}.", "overall_winner": "Totalsegrare. {driverName}.", + "timing_connected": "Tidtagningen är ansluten.", "timing_disconnected": "Tidtagningen är frånkopplad.", + "unknown_transponder": "Okänd transponder {transponder}.", "audio_test": "Test av raceljud." } }, "runtime": { - "start_delay_seconds": { - "min": 1.0, - "max": 4.0, - "randomize": true - }, - "passing_sound": "effects/transponder_pass.wav", - "start_sound": "effects/start_beep.wav", - "fastest_lap_sound": "effects/fastest_lap_trophy.wav", - "track_record_sound": "effects/track_record_fanfare.wav", - "finish_sound": "effects/race_finish.wav" + "start_delay_seconds": { "min": 1.0, "max": 4.0, "randomize": true }, + "passing_sound": "effects/transponder_pass.wav", "passing_sound_pool": ["pass_01.wav", "pass_02.wav", "pass_03.wav", "pass_04.wav", "pass_05.wav", "pass_06.wav", "pass_07.wav", "pass_08.wav"], + "start_sound": "effects/start_beep.wav", "fastest_lap_sound": "effects/fastest_lap_trophy.wav", + "track_record_sound": "effects/track_record_fanfare.wav", "finish_sound": "effects/race_finish.wav" } -} \ No newline at end of file +} diff --git a/server.js b/server.js index 499f42a..89f19d5 100644 --- a/server.js +++ b/server.js @@ -13,9 +13,11 @@ const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; const SRC_DIR = path.join(__dirname, "src"); +const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack"); const WATCHED_FILES = [ path.join(__dirname, "index.html"), path.join(__dirname, "public-training.html"), + path.join(RACE_SOUND_DIR, "sound_manifest.json"), ...fs.readdirSync(SRC_DIR) .filter((fileName) => /\.(?:js|css)$/i.test(fileName)) .map((fileName) => path.join(SRC_DIR, fileName)), @@ -324,6 +326,12 @@ app.get("/public-training/:shareToken", (req, res) => { } }); +app.use("/race-sounds", express.static(RACE_SOUND_DIR, { + setHeaders: (res, filePath) => { + res.setHeader("Cache-Control", filePath.endsWith(".wav") ? "public, max-age=86400" : "no-store"); + }, +})); + app.use( "/src", express.static(SRC_DIR, { diff --git a/src/app.js b/src/app.js index 7be9dd0..439b373 100644 --- a/src/app.js +++ b/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": diff --git a/src/core_views.js b/src/core_views.js index 8f53349..3d57220 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -390,6 +390,7 @@ export function renderDriversView(deps) { }) .join(""); const editingProfileId = editingDriver ? getDriverProfileId(editingDriver) : ""; + const passingSoundOptions = (selected = "") => [``, ...Array.from({ length: 8 }, (_item, index) => { const value = `pass_${String(index + 1).padStart(2, "0")}.wav`; return ``; })].join(""); const existingClubs = [...new Set(state.drivers.map((driver) => String(driver.club || "").trim()).filter(Boolean))].sort(); const existingCountries = [...new Set([ "Sverige", "Norge", "Finland", "Danmark", "Island", "Tyskland", "Nederländerna", "Belgien", "Frankrike", "Storbritannien", "Polen", "Tjeckien", "Österrike", "Schweiz", @@ -405,6 +406,7 @@ export function renderDriversView(deps) { +