feat: TTS with audio ducking
Some checks failed
TrafficCue CI / check (push) Has been cancelled
TrafficCue CI / build (push) Has been cancelled

This commit is contained in:
Cfp
2025-06-25 13:21:31 +02:00
parent a2a8255ebf
commit dc4679f072
12 changed files with 374 additions and 23 deletions

View File

@ -9,7 +9,8 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-native-audio')
implementation project(':capacitor-community-text-to-speech')
}

View File

@ -0,0 +1,55 @@
package de.trafficcue.trafficcue;
import android.content.Context;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.os.Build;
import androidx.annotation.RequiresApi;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "Duck")
public class DuckPlugin extends Plugin {
private AudioFocusRequest focusRequest;
private AudioManager audioManager;
@Override
public void load() {
super.load();
audioManager = (AudioManager) getBridge().getActivity().getSystemService(Context.AUDIO_SERVICE);
}
@RequiresApi(api = Build.VERSION_CODES.P)
@PluginMethod()
public void duck(PluginCall call) {
// String value = call.getString("value");
//
// JSObject ret = new JSObject();
// ret.put("value", value);
// call.resolve(ret);
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAcceptsDelayedFocusGain(false)
.setWillPauseWhenDucked(false)
.setForceDucking(true)
.build();
audioManager.requestAudioFocus(focusRequest);
call.resolve();
}
@RequiresApi(api = Build.VERSION_CODES.O)
@PluginMethod()
public void unduck(PluginCall call) {
audioManager.abandonAudioFocusRequest(focusRequest);
call.resolve();
}
}

View File

@ -1,5 +1,14 @@
package de.trafficcue.trafficcue;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(DuckPlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@ -1,3 +1,9 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-native-audio'
project(':capacitor-community-native-audio').projectDir = new File('../node_modules/@capacitor-community/native-audio/android')
include ':capacitor-community-text-to-speech'
project(':capacitor-community-text-to-speech').projectDir = new File('../node_modules/@capacitor-community/text-to-speech/android')

View File

@ -4,9 +4,12 @@
"": {
"name": "librenav",
"dependencies": {
"@capacitor-community/native-audio": "^7.0.0",
"@capacitor-community/text-to-speech": "^6.0.0",
"@capacitor/android": "^7.3.0",
"@capacitor/cli": "^7.3.0",
"@capacitor/core": "^7.3.0",
"@diffusionstudio/vits-web": "^1.0.3",
"@eslint/js": "^9.29.0",
"eslint": "^9.29.0",
"eslint-plugin-svelte": "^3.9.3",
@ -43,6 +46,10 @@
"@babel/runtime": ["@babel/runtime@7.27.3", "", {}, "sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw=="],
"@capacitor-community/native-audio": ["@capacitor-community/native-audio@7.0.0", "", { "peerDependencies": { "@capacitor/core": ">=7.0.0" } }, "sha512-wi2l68tU6KDLJWKL6I0lPUKOdB+hNxlvF7RMe/jfkKOMZnd1eUq7kszmbMqyGFZFIxdNG/GMiZrEztZHDypf8A=="],
"@capacitor-community/text-to-speech": ["@capacitor-community/text-to-speech@6.0.0", "", { "peerDependencies": { "@capacitor/core": ">=7.0.0" } }, "sha512-aKQ+S8q0T3Kqr2PdJI4xLksN8FFOqJI7vlX9sqWrPXPBWa8DQUSSV9M8tvjUq6B9+63er/KSe42ZKMVrD5usKw=="],
"@capacitor/android": ["@capacitor/android@7.3.0", "", { "peerDependencies": { "@capacitor/core": "^7.3.0" } }, "sha512-TqUm+Z3Uk7/rET+adNDKD63JtA0AN+pMQlFRcLPmZJHFmJl6bUOWWELVL0Ixl5XaweTxofNKeDAjjLRIs+wd3g=="],
"@capacitor/cli": ["@capacitor/cli@7.3.0", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.1", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^6.1.11", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-p0E1ayxw0Njpid8xwOrnuBncdakWxDMbUL2JhDUft38q8tscF2beIIMVhdna1t4Ow55H0r8sdTurwtSjtomrVw=="],
@ -55,6 +62,8 @@
"@deck.gl/mapbox": ["@deck.gl/mapbox@9.1.12", "", { "dependencies": { "@luma.gl/constants": "^9.1.5", "@math.gl/web-mercator": "^4.1.0" }, "peerDependencies": { "@deck.gl/core": "^9.1.0", "@luma.gl/core": "^9.1.5" } }, "sha512-xojtAncKWlURmF4uVqzdqtGkZhHjBWUs6vN1eRUR2ohVhtl5VAqxYGTiUij5gD/2mhoUKw8d53NybLMMN/vbyg=="],
"@diffusionstudio/vits-web": ["@diffusionstudio/vits-web@1.0.3", "", { "dependencies": { "onnxruntime-web": "1.18.0" } }, "sha512-xvOmc3CTWXGKbyiLbsrMczNArm7Z0Bi0h3pij0RTX6SytY/NSqd7h5DdPNUUhBt0nSjWGbo5RIrJLoCQTUQ0nQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="],
@ -229,6 +238,26 @@
"@probe.gl/stats": ["@probe.gl/stats@4.1.0", "", {}, "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.1", "", { "os": "android", "cpu": "arm" }, "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.41.1", "", { "os": "android", "cpu": "arm64" }, "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA=="],
@ -489,6 +518,8 @@
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatbuffers": ["flatbuffers@1.12.0", "", {}, "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@ -517,6 +548,8 @@
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"i18next": ["i18next@21.10.0", "", { "dependencies": { "@babel/runtime": "^7.17.2" } }, "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg=="],
@ -611,6 +644,8 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
@ -647,6 +682,10 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"onnxruntime-common": ["onnxruntime-common@1.18.0", "", {}, "sha512-lufrSzX6QdKrktAELG5x5VkBpapbCeS3dQwrXbN0eD9rHvU0yAWl7Ztju9FvgAKWvwd/teEKJNj3OwM6eTZh3Q=="],
"onnxruntime-web": ["onnxruntime-web@1.18.0", "", { "dependencies": { "flatbuffers": "^1.12.0", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.18.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-o1UKj4ABIj1gmG7ae0RKJ3/GT+3yoF0RRpfDfeoe0huzRW4FDRLfbkDETmdFAvnJEXuYDE0YT+hhkia0352StQ=="],
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"opening_hours": ["opening_hours@3.8.0", "", { "dependencies": { "i18next": "^21.8.3", "i18next-browser-languagedetector": "^6.1.4", "suncalc": "^1.9.0" } }, "sha512-bRJroECQSe/itVcNmC3j9PPicxn/LBowdd1Hi+4Aa7hCswdt7w81WHfUwrEMbtk1BBYmGJEbSepl8oYYPviSuA=="],
@ -675,6 +714,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"pmtiles": ["pmtiles@4.3.0", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA=="],
@ -699,6 +740,8 @@
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"protobufjs": ["protobufjs@7.5.3", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw=="],
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],

View File

@ -31,9 +31,12 @@
},
"type": "module",
"dependencies": {
"@capacitor-community/native-audio": "^7.0.0",
"@capacitor-community/text-to-speech": "^6.0.0",
"@capacitor/android": "^7.3.0",
"@capacitor/cli": "^7.3.0",
"@capacitor/core": "^7.3.0",
"@diffusionstudio/vits-web": "^1.0.3",
"@eslint/js": "^9.29.0",
"eslint": "^9.29.0",
"eslint-plugin-svelte": "^3.9.3",

View File

@ -27,6 +27,7 @@
import * as Popover from "../ui/popover";
import { routing } from "$lib/services/navigation/routing.svelte";
import InRouteSidebar from "./sidebar/InRouteSidebar.svelte";
import say from "$lib/services/navigation/TTS";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const views: Record<string, Component<any>> = {
@ -197,6 +198,15 @@
>
Join Remote Location
</Button>
<Button
variant="outline"
onclick={async () => {
// await say("This is a test of the text to speech system.");
await say("Dies ist ein Test des Text-zu-Sprache-Systems.");
}}
>
Test TTS
</Button>
</div>
</Popover.Content>
</Popover.Root>

View File

@ -0,0 +1,10 @@
import { registerPlugin } from "@capacitor/core";
export interface DuckPlugin {
duck: () => void;
unduck: () => void;
}
const Duck = registerPlugin<DuckPlugin>("Duck");
export default Duck;

View File

@ -0,0 +1,135 @@
import * as tts from '@diffusionstudio/vits-web';
import TTSWorker from './TTSWorker.ts?worker';
// const VOICE = "en_US-hfc_female-medium";
const VOICE = "de_DE-thorsten-medium";
export async function downloadVoice(): Promise<void> {
await tts.download(VOICE, (progress) => {
console.log(`Downloading ${progress.url} - ${Math.round(progress.loaded * 100 / progress.total)}%`);
});
}
interface TTSItem {
text: string;
audio?: Blob; // Optional audio blob if already generated
shouldPlay?: boolean; // Flag to indicate if the audio should be played immediately
}
const queue: TTSItem[] = [];
let playing = false;
let generating = 0;
const worker = new TTSWorker();
worker.addEventListener('message', (event: MessageEvent<{ type: 'result', audio: Blob, text: string }>) => {
if (event.data.type != 'result') return;
// const audio = new Audio();
// audio.src = URL.createObjectURL(event.data.audio);
// audio.play();
// console.log("Audio playing");
// audio.onended = () => {
// playing = false;
// };
const item = queue.find(item => item.text === event.data.text);
if (item) {
item.audio = event.data.audio; // Set the audio blob for the item
generating--;
}
});
setInterval(() => {
// if(playing) return;
// if(queue[0]) {
// playing = true;
// const text = queue.shift();
// console.log("Speaking:", text);
// if(text) {
// // tts.predict({
// // text,
// // voiceId: VOICE,
// // }).then((wav) => {
// // const audio = new Audio();
// // audio.src = URL.createObjectURL(wav);
// // audio.play();
// // audio.onended = () => {
// // playing = false;
// // };
// // }).catch((error) => {
// // console.error("Error playing audio:", error);
// // });
// worker.postMessage({
// type: 'init',
// text,
// voiceId: VOICE
// });
// }
// }
// Pregenerate audio one at a time
if (generating != 0) return;
for (const item of queue) {
// Generate audio blob if it doesn't exist
if (!item.audio) {
generating++;
console.log("Generating audio for:", item.text);
worker.postMessage({
type: 'init',
text: item.text,
voiceId: VOICE
});
item.audio = undefined; // Reset audio to undefined until generated
}
}
}, 100);
setInterval(() => {
if (playing) return;
if (queue.length === 0) return;
for (const item of queue) {
if (item.shouldPlay && item.audio) {
playing = true;
const audio = new Audio();
audio.src = URL.createObjectURL(item.audio);
audio.play();
audio.onended = () => {
playing = false;
};
queue.splice(queue.indexOf(item), 1); // Remove item from queue after playing
return; // Exit after playing one item
}
}
}, 100);
export function queueSpeech(text: string) {
// const wav = await tts.predict({
// text,
// voiceId: VOICE,
// });
// const audio = new Audio();
// audio.src = URL.createObjectURL(wav);
// audio.play();
if (queue.some(item => item.text === text)) {
// console.warn("Text already in queue, not adding again:", text);
return;
}
console.log("Queuing text for speech:", text);
queue.push({
text,
shouldPlay: false
});
}
export function speak(text: string) {
const existingItem = queue.find(item => item.text === text);
if (existingItem) {
existingItem.shouldPlay = true;
} else {
console.warn("Adding new item to play immediately. Consider queuing instead!");
queue.push({
text,
shouldPlay: true
});
}
}

View File

@ -0,0 +1,46 @@
import type { TextToSpeechPlugin } from "@capacitor-community/text-to-speech";
import { Capacitor } from "@capacitor/core";
import Duck from "../DuckPlugin";
export let tts: TextToSpeechPlugin | "web" | null = null;
export async function initTTS() {
if(Capacitor.isNativePlatform()) {
console.log("Using Capacitor TTS");
tts = (await import("@capacitor-community/text-to-speech")).TextToSpeech;
} else {
console.log("Using Web TTS");
tts = "web";
}
}
export default async function say(text: string) {
if(!tts) {
// alert("TTS not initialized");
// console.error("TTS not initialized");
await initTTS();
// return;
}
console.log("A");
Duck.duck();
console.log("B");
if(tts !== "web") {
try {
await tts?.speak({
text: text,
lang: "deu-default", // TODO: make this configurable
});
console.log("C");
} catch (e) {
console.error("Error speaking text", e);
alert(e);
}
} else {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "de-DE";
window.speechSynthesis.speak(utterance);
}
console.log("D");
Duck.unduck();
console.log("E");
}

View File

@ -0,0 +1,16 @@
import * as tts from '@diffusionstudio/vits-web';
async function main(event: MessageEvent<tts.InferenceConfg & { type: 'init' }>) {
if (event.data?.type != 'init') return;
const start = performance.now();
const blob = await tts.predict({
text: event.data.text,
voiceId: event.data.voiceId,
});
console.log('Time taken:', performance.now() - start + ' ms');
self.postMessage({ type: 'result', audio: blob, text: event.data.text });
}
self.addEventListener('message', main);

View File

@ -1,5 +1,6 @@
import { location } from "$lib/components/lnv/location.svelte";
import { map } from "$lib/components/lnv/map.svelte";
import say from "./TTS";
import type { ValhallaRequest } from "./ValhallaRequest";
import type { LngLatBoundsLike } from "maplibre-gl";
@ -156,18 +157,21 @@ async function tickRoute() {
}
const bgi = currentManeuver.begin_shape_index;
const location = getUserLocation();
const loc = {
lat: location.lat,
lon: location.lng,
};
const polyline = decodePolyline(trip.legs[0].shape);
// Check if the user location is on the last point of the entire route
if (isOnPoint(location, polyline[polyline.length - 1])) {
if (isOnPoint(loc, polyline[polyline.length - 1])) {
console.log("Reached destination!");
stopNavigation();
return;
}
// Check if the user is on the route
if (!isOnShape(location, polyline)) {
if (!isOnShape(loc, polyline)) {
console.log("Off route!");
info.isOffRoute = true;
// TODO: Implement re-routing logic
@ -180,20 +184,25 @@ async function tickRoute() {
currentManeuver.verbal_pre_transition_instruction &&
!hasAnnouncedPreInstruction
) {
const distanceToEnd = calculateDistance(location, polyline[bgi]);
const distanceToEnd = calculateDistance(loc, polyline[bgi]);
// console.log("Distance to end of current maneuver: ", distanceToEnd, " meters");
if (distanceToEnd <= 100) {
console.log("Speed: ", location.speed, " km/h");
const verbalDistance = verbalPreInstructionDistance(
location.speed || 50, // Assuming location has a speed property
);
if (distanceToEnd <= verbalDistance) {
hasAnnouncedPreInstruction = true;
console.log(
"[Verbal instruction] ",
currentManeuver.verbal_pre_transition_instruction,
);
say(currentManeuver.verbal_pre_transition_instruction);
}
}
// Check if the user is past the current maneuver
// Checks if the user is still on the current maneuver's polyline
if (!isOnShape(location, polyline.slice(bgi))) {
if (!isOnShape(loc, polyline.slice(bgi))) {
return; // User is not on the current maneuver's polyline, do not update
}
@ -208,7 +217,7 @@ async function tickRoute() {
if (currentManeuver.verbal_post_transition_instruction) {
hasAnnouncedPreInstruction = false;
const distanceToEnd = calculateDistance(
location,
loc,
polyline[
trip.legs[0].maneuvers[routing.currentTripInfo.maneuverIdx + 1]
.begin_shape_index
@ -219,6 +228,7 @@ async function tickRoute() {
"[Verbal instruction] ",
currentManeuver.verbal_post_transition_instruction,
);
say(currentManeuver.verbal_post_transition_instruction);
}
}
@ -232,9 +242,16 @@ async function tickRoute() {
info.currentManeuver = trip.legs[0].maneuvers[info.maneuverIdx];
// queueSpeech(info.currentManeuver.verbal_pre_transition_instruction || "");
// queueSpeech(info.currentManeuver.verbal_post_transition_instruction || "");
// TODO: verbal instructions
}
function verbalPreInstructionDistance(speed: number): number {
return (speed * 2.222) + 37.144;
}
export function stopNavigation() {
if (routing.currentTripInfo.int) {
clearInterval(routing.currentTripInfo.int);
@ -245,20 +262,20 @@ export function stopNavigation() {
removeAllRoutes();
}
function getUserLocation(): WorldLocation {
// return geolocate.currentLocation!;
return {
lat: location.lat,
lon: location.lng,
};
// const lnglat = window.geolocate._userLocationDotMarker.getLngLat();
// return { lat: lnglat.lat, lon: lnglat.lng };
// console.log(map.value!)
// return {
// lat: 0,
// lon: 0
// }
}
// function getUserLocation(): WorldLocation {
// // return geolocate.currentLocation!;
// return {
// lat: location.lat,
// lon: location.lng,
// };
// // const lnglat = window.geolocate._userLocationDotMarker.getLngLat();
// // return { lat: lnglat.lat, lon: lnglat.lng };
// // console.log(map.value!)
// // return {
// // lat: 0,
// // lon: 0
// // }
// }
function isOnLine(
location: WorldLocation,