add custome voice

This commit is contained in:
larssand
2026-08-28 13:47:25 +02:00
parent fa3e5ee379
commit 34e55ff7f6
9 changed files with 31 additions and 7 deletions

View File

@@ -540,7 +540,7 @@ Example message:
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.
2. Select `sv` or `en`, choose an installed Race voice, then set its speed, pitch, and master/effects/voice/passing volume. The offline race-controller defaults are speed `1.08` and pitch `0.90`; prerecorded or neural clips are still required for genuinely human announcer quality.
3. Enable or disable effect and voice independently for each event and use its Test button. Changes save automatically, and the section also has its own Save button. WAV effects are prerecorded; voice announcements use the operating system Web Speech voices.
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.

View File

@@ -529,7 +529,7 @@ Exempelmeddelande:
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.
2. Välj `sv` eller `en`, välj en installerad Race-röst och ställ dess hastighet, tonhöjd samt master-, effekt-, röst- och passing-volym. Offline-standard för race-controller-stil är hastighet `1.08` och tonhöjd `0.90`; för verkligt mänsklig speakerkvalitet krävs fortfarande förinspelade eller neurala klipp.
3. Slå på/av effekt och röst separat för varje event och använd radens Testa-knapp. Ändringar sparas automatiskt och sektionen har även en egen Spara-knapp. WAV-effekter är förinspelade; röstutrop använder operativsystemets Web Speech-röster.
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.

View File

@@ -2444,6 +2444,7 @@ The service is constructed in `src/app.js`. `src/app.js` is the composition root
```js
{
enabled, muted, language,
voiceUri, voiceRate, voicePitch,
masterVolume, effectsVolume, voiceVolume, passingVolume,
startCountdownSeconds,
startLights, lightIntervalMs,

View File

@@ -1013,6 +1013,10 @@ const TRANSLATIONS = {
"audio.enabled": "Aktivera nya raceljud",
"audio.mute": "Tysta allt",
"audio.language": "Utropsspråk",
"audio.voice_select": "Race-röst",
"audio.voice_auto": "Automatisk röst för valt språk",
"audio.voice_rate": "Race-röstens hastighet",
"audio.voice_pitch": "Race-röstens tonhöjd",
"audio.start_countdown": "Nedräkning före start",
"audio.master_volume": "Mastervolym",
"audio.effects_volume": "Effektvolym",
@@ -2120,6 +2124,10 @@ const TRANSLATIONS = {
"audio.enabled": "Enable new race audio",
"audio.mute": "Mute all",
"audio.language": "Announcement language",
"audio.voice_select": "Race voice",
"audio.voice_auto": "Automatic voice for selected language",
"audio.voice_rate": "Race voice speed",
"audio.voice_pitch": "Race voice pitch",
"audio.start_countdown": "Pre-start countdown",
"audio.master_volume": "Master volume",
"audio.effects_volume": "Effects volume",
@@ -2744,7 +2752,7 @@ const raceAudioEngine = createRaceAudioEngine({
speak: (text, options = {}) => new Promise((resolve) => {
let settled = false;
const done = (value) => { if (!settled) { settled = true; resolve(value); } };
const started = speakText(text, { ...options, voiceUri: "", onEnd: () => done(true), onError: () => done(false) });
const started = speakText(text, { ...options, voiceUri: state.settings.raceAudio?.voiceUri || "", rate: state.settings.raceAudio?.voiceRate, pitch: state.settings.raceAudio?.voicePitch, onEnd: () => done(true), onError: () => done(false) });
if (!started) done(false);
else setTimeout(() => done(true), 15000);
}),

View File

@@ -64,6 +64,9 @@ export function normalizeRaceAudioSettings(value = {}) {
enabled: source.enabled !== false,
muted: source.muted === true,
language: source.language === "en" ? "en" : "sv",
voiceUri: String(source.voiceUri || ""),
voiceRate: clamp(source.voiceRate, 1.08, 0.65, 1.35),
voicePitch: clamp(source.voicePitch, 0.9, 0.7, 1.3),
masterVolume: clamp(source.masterVolume ?? source.volume?.master, 0.8),
effectsVolume: clamp(source.effectsVolume ?? source.volume?.effects, 0.8),
voiceVolume: clamp(source.voiceVolume ?? source.volume?.voice, 0.9),

View File

@@ -335,8 +335,8 @@ export function speakTextHelper(text, { state, currentLanguage }, options = {})
const utterance = new SpeechSynthesisUtterance(text);
const language = options.language || currentLanguage();
utterance.lang = String(language).toLowerCase().startsWith("sv") ? "sv-SE" : "en-US";
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));
utterance.rate = Math.max(0.65, Math.min(1.35, Number(options.rate ?? state.settings.speechRate ?? 0.95) || 0.95));
utterance.pitch = Math.max(0.7, Math.min(1.3, Number(options.pitch ?? state.settings.speechPitch ?? 1) || 1));
utterance.volume = Math.max(0, Math.min(1, Number(options.volume ?? 1)));
if (typeof options.onEnd === "function") utterance.onend = options.onEnd;
if (typeof options.onError === "function") utterance.onerror = options.onError;

View File

@@ -54,10 +54,13 @@ export function renderSettings(deps) {
const suggestedPitExitWsUrl = getManagedWsUrl("pit_exit");
const running = Boolean(ammcStatus.running);
const speechVoices = getSpeechVoices();
const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
const speechVoiceOptions = speechVoices
.map((voice) => `<option value="${escapeHtml(voice.voiceURI)}" ${state.settings.speechVoiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
.join("");
const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
const raceAudioVoiceOptions = speechVoices
.map((voice) => `<option value="${escapeHtml(voice.voiceURI)}" ${raceAudio.voiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
.join("");
const raceAudioEventRows = raceAudioEventDefinitions.map((definition) => `
<tr>
<td><strong>${escapeHtml(definition.key.replaceAll("_", " "))}</strong><small>${escapeHtml(definition.group)} · ${escapeHtml(definition.priority)}</small></td>
@@ -176,6 +179,9 @@ export function renderSettings(deps) {
<label class="toggle"><input type="checkbox" name="raceAudioEnabled" form="settingsForm" ${raceAudio.enabled ? "checked" : ""} /><span>${t("audio.enabled")}</span></label>
<label class="toggle"><input type="checkbox" name="raceAudioMuted" form="settingsForm" ${raceAudio.muted ? "checked" : ""} /><span>${t("audio.mute")}</span></label>
<label><span>${t("audio.language")}</span><select name="raceAudioLanguage" form="settingsForm"><option value="sv" ${raceAudio.language === "sv" ? "selected" : ""}>Svenska</option><option value="en" ${raceAudio.language === "en" ? "selected" : ""}>English</option></select></label>
<label><span>${t("audio.voice_select")}</span><select name="raceAudioVoiceUri" form="settingsForm"><option value="">${t("audio.voice_auto")}</option>${raceAudioVoiceOptions}</select></label>
<label><span>${t("audio.voice_rate")}</span><input type="range" min="0.65" max="1.35" step="0.05" name="raceAudioVoiceRate" form="settingsForm" value="${raceAudio.voiceRate}" /></label>
<label><span>${t("audio.voice_pitch")}</span><input type="range" min="0.7" max="1.3" step="0.05" name="raceAudioVoicePitch" form="settingsForm" value="${raceAudio.voicePitch}" /></label>
<label><span>${t("audio.start_countdown")}</span><select name="raceAudioCountdown" form="settingsForm">${[0,10,30,60].map((value) => `<option value="${value}" ${raceAudio.startCountdownSeconds === value ? "selected" : ""}>${value ? `${value} s` : t("common.off")}</option>`).join("")}</select></label>
<label><span>${t("audio.master_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioMaster" form="settingsForm" value="${raceAudio.masterVolume}" /></label>
<label><span>${t("audio.effects_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioEffects" form="settingsForm" value="${raceAudio.effectsVolume}" /></label>
@@ -475,6 +481,8 @@ export function renderSettings(deps) {
enabled: form.get("raceAudioEnabled") === "on",
muted: form.get("raceAudioMuted") === "on",
language: String(form.get("raceAudioLanguage") || "sv"),
voiceUri: String(form.get("raceAudioVoiceUri") || ""),
voiceRate: Number(form.get("raceAudioVoiceRate")), voicePitch: Number(form.get("raceAudioVoicePitch")),
masterVolume: Number(form.get("raceAudioMaster")), effectsVolume: Number(form.get("raceAudioEffects")),
voiceVolume: Number(form.get("raceAudioVoice")), passingVolume: Number(form.get("raceAudioPassing")),
startCountdownSeconds: Number(form.get("raceAudioCountdown")), startLights: form.get("raceAudioStartLights") === "on",

View File

@@ -114,10 +114,12 @@ test("race audio can ignore a mismatched configured voice and select the announc
const started = speakTextHelper("Race finished", {
state: { settings: { audioEnabled: true, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } },
currentLanguage: () => "sv",
}, { language: "en", voiceUri: "" });
}, { language: "en", voiceUri: "", rate: 1.1, pitch: 0.85 });
assert.equal(started, true);
assert.equal(spoken[0].lang, "en-US");
assert.equal(spoken[0].voice.voiceURI, "voice-en");
assert.equal(spoken[0].rate, 1.1);
assert.equal(spoken[0].pitch, 0.85);
} finally {
globalThis.window = originalWindow;
globalThis.SpeechSynthesisUtterance = OriginalUtterance;

View File

@@ -12,6 +12,8 @@ test("race audio settings normalize volume, delay, language and switches", () =>
assert.equal(settings.masterVolume, 1);
assert.equal(settings.effectsVolume, 0);
assert.equal(settings.language, "en");
assert.equal(settings.voiceRate, 1.08);
assert.equal(settings.voicePitch, 0.9);
assert.equal(settings.minStartDelay, 2);
assert.equal(settings.maxStartDelay, 2);
assert.equal(settings.events.transponder_pass.effect, false);