Förbättrat ljudsystemet

This commit is contained in:
larssand
2026-08-24 19:40:39 +02:00
parent 2e6866a013
commit 355a1ad3b0
13 changed files with 345 additions and 47 deletions

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
);

View File

@@ -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,

View File

@@ -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 })}

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -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();

View 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 }]);
});

View File

@@ -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";