@pickleball/expo-sdk 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app.plugin.js +6 -0
- package/dist/chunk-VHLUJQG5.js +17 -0
- package/dist/index.cjs +6284 -0
- package/dist/index.d.cts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +6186 -0
- package/dist/plugin.cjs +153 -0
- package/dist/plugin.d.cts +21 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +131 -0
- package/dist/version.cjs +35 -0
- package/dist/version.d.cts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +126 -0
- package/src/contracts.ts +211 -0
- package/src/engine.ts +1332 -0
- package/src/errors.ts +128 -0
- package/src/http-session-provider.ts +259 -0
- package/src/index.ts +16 -0
- package/src/livekit-room-adapter.ts +353 -0
- package/src/livestream-screen.tsx +980 -0
- package/src/match/contracts.ts +137 -0
- package/src/match/engine.ts +702 -0
- package/src/match/persistence/default-store.native.ts +20 -0
- package/src/match/persistence/default-store.ts +11 -0
- package/src/match/persistence/index.ts +6 -0
- package/src/match/persistence/memory-driver.ts +255 -0
- package/src/match/persistence/migrations.ts +40 -0
- package/src/match/persistence/sqlite-driver.ts +348 -0
- package/src/match/persistence/store.ts +497 -0
- package/src/match/persistence/types.ts +98 -0
- package/src/match/provider.tsx +728 -0
- package/src/match/remote/deduplicator.ts +54 -0
- package/src/match/remote/encoding.ts +12 -0
- package/src/match/remote/hid-gesture.ts +61 -0
- package/src/match/remote/index.ts +5 -0
- package/src/match/remote/native-service.ts +62 -0
- package/src/match/remote/parser.ts +134 -0
- package/src/match/remote/types.ts +116 -0
- package/src/match/replay.ts +102 -0
- package/src/match/state.ts +72 -0
- package/src/match/validation.ts +375 -0
- package/src/match/voice/assets/court-left.wav +0 -0
- package/src/match/voice/assets/court-right.wav +0 -0
- package/src/match/voice/assets/court-switch.wav +0 -0
- package/src/match/voice/assets/game-point.wav +0 -0
- package/src/match/voice/assets/game-won.wav +0 -0
- package/src/match/voice/assets/match-won.wav +0 -0
- package/src/match/voice/assets/number-0.wav +0 -0
- package/src/match/voice/assets/number-1.wav +0 -0
- package/src/match/voice/assets/number-10.wav +0 -0
- package/src/match/voice/assets/number-11.wav +0 -0
- package/src/match/voice/assets/number-12.wav +0 -0
- package/src/match/voice/assets/number-13.wav +0 -0
- package/src/match/voice/assets/number-14.wav +0 -0
- package/src/match/voice/assets/number-15.wav +0 -0
- package/src/match/voice/assets/number-16.wav +0 -0
- package/src/match/voice/assets/number-17.wav +0 -0
- package/src/match/voice/assets/number-18.wav +0 -0
- package/src/match/voice/assets/number-19.wav +0 -0
- package/src/match/voice/assets/number-2.wav +0 -0
- package/src/match/voice/assets/number-20.wav +0 -0
- package/src/match/voice/assets/number-3.wav +0 -0
- package/src/match/voice/assets/number-30.wav +0 -0
- package/src/match/voice/assets/number-4.wav +0 -0
- package/src/match/voice/assets/number-40.wav +0 -0
- package/src/match/voice/assets/number-5.wav +0 -0
- package/src/match/voice/assets/number-50.wav +0 -0
- package/src/match/voice/assets/number-6.wav +0 -0
- package/src/match/voice/assets/number-60.wav +0 -0
- package/src/match/voice/assets/number-7.wav +0 -0
- package/src/match/voice/assets/number-70.wav +0 -0
- package/src/match/voice/assets/number-8.wav +0 -0
- package/src/match/voice/assets/number-80.wav +0 -0
- package/src/match/voice/assets/number-9.wav +0 -0
- package/src/match/voice/assets/number-90.wav +0 -0
- package/src/match/voice/assets/number-lam.wav +0 -0
- package/src/match/voice/assets/number-mot.wav +0 -0
- package/src/match/voice/assets/score-deu-0.wav +0 -0
- package/src/match/voice/assets/score-deu-1.wav +0 -0
- package/src/match/voice/assets/score-deu-10.wav +0 -0
- package/src/match/voice/assets/score-deu-11.wav +0 -0
- package/src/match/voice/assets/score-deu-12.wav +0 -0
- package/src/match/voice/assets/score-deu-13.wav +0 -0
- package/src/match/voice/assets/score-deu-14.wav +0 -0
- package/src/match/voice/assets/score-deu-15.wav +0 -0
- package/src/match/voice/assets/score-deu-16.wav +0 -0
- package/src/match/voice/assets/score-deu-17.wav +0 -0
- package/src/match/voice/assets/score-deu-18.wav +0 -0
- package/src/match/voice/assets/score-deu-19.wav +0 -0
- package/src/match/voice/assets/score-deu-2.wav +0 -0
- package/src/match/voice/assets/score-deu-20.wav +0 -0
- package/src/match/voice/assets/score-deu-3.wav +0 -0
- package/src/match/voice/assets/score-deu-30.wav +0 -0
- package/src/match/voice/assets/score-deu-4.wav +0 -0
- package/src/match/voice/assets/score-deu-40.wav +0 -0
- package/src/match/voice/assets/score-deu-5.wav +0 -0
- package/src/match/voice/assets/score-deu-50.wav +0 -0
- package/src/match/voice/assets/score-deu-6.wav +0 -0
- package/src/match/voice/assets/score-deu-60.wav +0 -0
- package/src/match/voice/assets/score-deu-7.wav +0 -0
- package/src/match/voice/assets/score-deu-70.wav +0 -0
- package/src/match/voice/assets/score-deu-8.wav +0 -0
- package/src/match/voice/assets/score-deu-80.wav +0 -0
- package/src/match/voice/assets/score-deu-9.wav +0 -0
- package/src/match/voice/assets/score-deu-90.wav +0 -0
- package/src/match/voice/assets/score-deu-lam.wav +0 -0
- package/src/match/voice/assets/score-deu-mot.wav +0 -0
- package/src/match/voice/assets/server-1.wav +0 -0
- package/src/match/voice/assets/server-2.wav +0 -0
- package/src/match/voice/assets/service-change.wav +0 -0
- package/src/match/voice/assets/test-speaker.wav +0 -0
- package/src/match/voice/audio.ts +231 -0
- package/src/match/voice/bundled-assets.native.ts +47 -0
- package/src/match/voice/bundled-assets.ts +3 -0
- package/src/match/voice/gemini-engine.ts +167 -0
- package/src/match/voice/index.ts +7 -0
- package/src/match/voice/phrases.ts +185 -0
- package/src/match/voice/scheduler.ts +205 -0
- package/src/match/voice/service.ts +147 -0
- package/src/match/voice/types.ts +118 -0
- package/src/native-runtime.ts +241 -0
- package/src/plugin.ts +145 -0
- package/src/provider.tsx +176 -0
- package/src/version.ts +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { planBundledFallback } from "./phrases";
|
|
2
|
+
import type {
|
|
3
|
+
AnnouncementPlan,
|
|
4
|
+
AudioPlayback,
|
|
5
|
+
NeuralVoiceEngine,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export interface LatestAnnouncementSchedulerOptions {
|
|
9
|
+
readonly engine: NeuralVoiceEngine;
|
|
10
|
+
readonly audio: AudioPlayback;
|
|
11
|
+
readonly onError?: (error: Error) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class LatestAnnouncementScheduler {
|
|
15
|
+
private static readonly MAX_CACHE_ENTRIES = 32;
|
|
16
|
+
private generation = 0;
|
|
17
|
+
private activeController: AbortController | null = null;
|
|
18
|
+
private lastPlan: AnnouncementPlan | null = null;
|
|
19
|
+
private readonly neuralCache = new Map<string, string>();
|
|
20
|
+
private readonly operations = new Set<Promise<void>>();
|
|
21
|
+
private readonly prewarmControllers = new Set<AbortController>();
|
|
22
|
+
private lifecycle: "active" | "disposing" | "disposed" = "active";
|
|
23
|
+
private disposePromise: Promise<void> | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly options: LatestAnnouncementSchedulerOptions) {}
|
|
26
|
+
|
|
27
|
+
prewarm(commonPhrases: readonly string[] = []): Promise<void> {
|
|
28
|
+
if (this.lifecycle !== "active") return Promise.resolve();
|
|
29
|
+
const generation = this.generation;
|
|
30
|
+
return this.track((async () => {
|
|
31
|
+
try {
|
|
32
|
+
await this.options.engine.prewarm(commonPhrases);
|
|
33
|
+
if (this.lifecycle !== "active" || generation !== this.generation) return;
|
|
34
|
+
const availability = await this.options.engine.availability();
|
|
35
|
+
if (this.lifecycle !== "active" || generation !== this.generation) return;
|
|
36
|
+
if (availability === "available") {
|
|
37
|
+
for (const phrase of commonPhrases) {
|
|
38
|
+
if (this.lifecycle !== "active" || generation !== this.generation) return;
|
|
39
|
+
if (this.neuralCache.has(phrase)) continue;
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
this.prewarmControllers.add(controller);
|
|
42
|
+
let file: string;
|
|
43
|
+
try {
|
|
44
|
+
file = await this.options.engine.synthesize(phrase, controller.signal);
|
|
45
|
+
} finally {
|
|
46
|
+
this.prewarmControllers.delete(controller);
|
|
47
|
+
}
|
|
48
|
+
if (this.lifecycle !== "active" || generation !== this.generation || controller.signal.aborted) {
|
|
49
|
+
await this.options.engine.release?.(file);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await this.cacheFile(phrase, file);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.report(error);
|
|
57
|
+
}
|
|
58
|
+
})());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
enqueue(plan: AnnouncementPlan): Promise<void> {
|
|
62
|
+
if (this.lifecycle !== "active") return Promise.resolve();
|
|
63
|
+
return this.track(this.performEnqueue(plan));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async performEnqueue(plan: AnnouncementPlan): Promise<void> {
|
|
67
|
+
this.lastPlan = plan;
|
|
68
|
+
const generation = ++this.generation;
|
|
69
|
+
this.activeController?.abort();
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
this.activeController = controller;
|
|
72
|
+
await Promise.allSettled([
|
|
73
|
+
this.options.engine.cancel(),
|
|
74
|
+
this.options.audio.stop(),
|
|
75
|
+
]);
|
|
76
|
+
if (generation !== this.generation) return;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const avail = await this.options.engine.availability();
|
|
80
|
+
if (avail === "available") {
|
|
81
|
+
let file = this.touchCachedFile(plan.text);
|
|
82
|
+
if (!file) {
|
|
83
|
+
file = await this.options.engine.synthesize(plan.text, controller.signal);
|
|
84
|
+
await this.cacheFile(plan.text, file);
|
|
85
|
+
}
|
|
86
|
+
if (this.lifecycle !== "active" || generation !== this.generation || controller.signal.aborted) return;
|
|
87
|
+
await this.options.audio.play(file);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (controller.signal.aborted || generation !== this.generation) return;
|
|
92
|
+
this.report(error);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const source of planBundledFallback(plan)) {
|
|
96
|
+
if (generation !== this.generation || controller.signal.aborted) return;
|
|
97
|
+
try {
|
|
98
|
+
await this.options.audio.play(source);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.report(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async replayLast(): Promise<void> {
|
|
106
|
+
if (this.lifecycle !== "active") return;
|
|
107
|
+
if (this.lastPlan) await this.enqueue(this.lastPlan);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
testSpeaker(): Promise<void> {
|
|
111
|
+
if (this.lifecycle !== "active") return Promise.resolve();
|
|
112
|
+
return this.track(this.performSpeakerTest());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async performSpeakerTest(): Promise<void> {
|
|
116
|
+
const generation = ++this.generation;
|
|
117
|
+
this.activeController?.abort();
|
|
118
|
+
await Promise.allSettled([
|
|
119
|
+
this.options.engine.cancel(),
|
|
120
|
+
this.options.audio.stop(),
|
|
121
|
+
]);
|
|
122
|
+
if (generation === this.generation) {
|
|
123
|
+
try {
|
|
124
|
+
await this.options.audio.play("asset://voice/test-speaker.wav");
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.report(error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async dispose(): Promise<void> {
|
|
132
|
+
if (this.disposePromise) return this.disposePromise;
|
|
133
|
+
this.lifecycle = "disposing";
|
|
134
|
+
this.generation += 1;
|
|
135
|
+
this.activeController?.abort();
|
|
136
|
+
this.activeController = null;
|
|
137
|
+
for (const controller of this.prewarmControllers) controller.abort();
|
|
138
|
+
this.prewarmControllers.clear();
|
|
139
|
+
this.disposePromise = (async () => {
|
|
140
|
+
await Promise.allSettled([
|
|
141
|
+
this.options.engine.cancel(),
|
|
142
|
+
this.options.audio.stop(),
|
|
143
|
+
]);
|
|
144
|
+
await Promise.allSettled([...this.operations]);
|
|
145
|
+
await this.clearCache();
|
|
146
|
+
await Promise.allSettled([
|
|
147
|
+
this.options.engine.dispose(),
|
|
148
|
+
this.options.audio.dispose(),
|
|
149
|
+
]);
|
|
150
|
+
this.lifecycle = "disposed";
|
|
151
|
+
})();
|
|
152
|
+
return this.disposePromise;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async clearCache(): Promise<void> {
|
|
156
|
+
const files = [...new Set(this.neuralCache.values())];
|
|
157
|
+
this.neuralCache.clear();
|
|
158
|
+
if (this.options.engine.release) {
|
|
159
|
+
await Promise.allSettled(files.map((file) => this.options.engine.release!(file)));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async cacheFile(text: string, file: string): Promise<void> {
|
|
164
|
+
if (this.lifecycle !== "active") {
|
|
165
|
+
await this.options.engine.release?.(file);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const replaced = this.neuralCache.get(text);
|
|
169
|
+
if (replaced && replaced !== file) await this.options.engine.release?.(replaced);
|
|
170
|
+
this.neuralCache.delete(text);
|
|
171
|
+
this.neuralCache.set(text, file);
|
|
172
|
+
while (this.neuralCache.size > LatestAnnouncementScheduler.MAX_CACHE_ENTRIES) {
|
|
173
|
+
const oldest = this.neuralCache.entries().next().value as [string, string] | undefined;
|
|
174
|
+
if (!oldest) break;
|
|
175
|
+
this.neuralCache.delete(oldest[0]);
|
|
176
|
+
await this.options.engine.release?.(oldest[1]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private touchCachedFile(text: string): string | undefined {
|
|
181
|
+
const file = this.neuralCache.get(text);
|
|
182
|
+
if (!file) return undefined;
|
|
183
|
+
this.neuralCache.delete(text);
|
|
184
|
+
this.neuralCache.set(text, file);
|
|
185
|
+
return file;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private track(operation: Promise<void>): Promise<void> {
|
|
189
|
+
this.operations.add(operation);
|
|
190
|
+
operation.then(
|
|
191
|
+
() => this.operations.delete(operation),
|
|
192
|
+
() => this.operations.delete(operation),
|
|
193
|
+
);
|
|
194
|
+
return operation;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private report(caught: unknown): void {
|
|
198
|
+
const error = caught instanceof Error ? caught : new Error("Voice operation failed");
|
|
199
|
+
try {
|
|
200
|
+
this.options.onError?.(error);
|
|
201
|
+
} catch {
|
|
202
|
+
// Voice observers cannot affect match scoring or fallback playback.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createExpoAudioPlayback } from "./audio";
|
|
2
|
+
import { resolveBundledVoiceAsset } from "./bundled-assets";
|
|
3
|
+
import { createGeminiVoiceEngine, createUnavailableVoiceEngine } from "./gemini-engine";
|
|
4
|
+
import { buildVietnameseAnnouncement } from "./phrases";
|
|
5
|
+
import { LatestAnnouncementScheduler } from "./scheduler";
|
|
6
|
+
import type {
|
|
7
|
+
AudioPlayback,
|
|
8
|
+
MatchVoiceService,
|
|
9
|
+
NeuralVoiceEngine,
|
|
10
|
+
VoiceAudioMode,
|
|
11
|
+
VoiceRouteState,
|
|
12
|
+
VoiceTtsOptions,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
export interface DefaultMatchVoiceServiceOptions {
|
|
16
|
+
/** Cloud TTS endpoint. Without it, announcements always use the bundled WAVs. */
|
|
17
|
+
readonly tts?: VoiceTtsOptions;
|
|
18
|
+
readonly audioMode?: VoiceAudioMode;
|
|
19
|
+
/** Exact cache directory for generated WAVs. Primarily useful for tests. */
|
|
20
|
+
readonly generatedRoot?: string;
|
|
21
|
+
/** Primarily useful for tests. Defaults to expo-file-system removal. */
|
|
22
|
+
readonly removeGeneratedDirectory?: (path: string) => Promise<void>;
|
|
23
|
+
/** Primarily useful for native integration tests. */
|
|
24
|
+
readonly audio?: AudioPlayback;
|
|
25
|
+
/** Primarily useful for native integration tests. */
|
|
26
|
+
readonly engine?: NeuralVoiceEngine;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare const require: (name: string) => any;
|
|
30
|
+
|
|
31
|
+
function removeDirectory(path: string): void {
|
|
32
|
+
const fs = require("expo-file-system");
|
|
33
|
+
const info = fs.Paths.info(path);
|
|
34
|
+
if (!info.exists) return;
|
|
35
|
+
if (info.isDirectory) new fs.Directory(path).delete();
|
|
36
|
+
else new fs.File(path).delete();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createDefaultMatchVoiceService(
|
|
40
|
+
options: DefaultMatchVoiceServiceOptions = {},
|
|
41
|
+
): MatchVoiceService {
|
|
42
|
+
const listeners = new Set<() => void>();
|
|
43
|
+
let lastError: Error | null = null;
|
|
44
|
+
let disposed = false;
|
|
45
|
+
let lifecycle: "active" | "disposing" | "disposed" = "active";
|
|
46
|
+
let initialized = false;
|
|
47
|
+
let operationTail: Promise<void> = Promise.resolve();
|
|
48
|
+
let disposePromise: Promise<void> | null = null;
|
|
49
|
+
let notificationQueue = Promise.resolve();
|
|
50
|
+
const audio = options.audio ?? createExpoAudioPlayback({ resolveSource: resolveBundledVoiceAsset });
|
|
51
|
+
const generatedDirectory = () =>
|
|
52
|
+
options.generatedRoot ??
|
|
53
|
+
`${require("expo-file-system").Paths.cache.uri}/pickleball-voice-generated/gemini`;
|
|
54
|
+
const engine =
|
|
55
|
+
options.engine ??
|
|
56
|
+
(options.tts
|
|
57
|
+
? createGeminiVoiceEngine({ ...options.tts, generatedRoot: generatedDirectory() })
|
|
58
|
+
: createUnavailableVoiceEngine());
|
|
59
|
+
const scheduler = new LatestAnnouncementScheduler({
|
|
60
|
+
engine,
|
|
61
|
+
audio,
|
|
62
|
+
onError(error) {
|
|
63
|
+
lastError = error;
|
|
64
|
+
enqueueNotify();
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function notify() {
|
|
69
|
+
for (const listener of listeners) listener();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function enqueueNotify() {
|
|
73
|
+
notificationQueue = notificationQueue.then(() => {
|
|
74
|
+
if (!disposed) notify();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const unsubscribeAudio = audio.subscribeToRouteChanges?.(() => enqueueNotify());
|
|
79
|
+
|
|
80
|
+
async function sweepGeneratedAudio() {
|
|
81
|
+
await scheduler.clearCache();
|
|
82
|
+
try {
|
|
83
|
+
if (options.removeGeneratedDirectory) {
|
|
84
|
+
await options.removeGeneratedDirectory(generatedDirectory());
|
|
85
|
+
} else {
|
|
86
|
+
removeDirectory(generatedDirectory());
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Best-effort cleanup of stale generated audio.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runActive(operation: () => Promise<void>): Promise<void> {
|
|
94
|
+
if (lifecycle !== "active") return Promise.resolve();
|
|
95
|
+
const result = operationTail.then(async () => {
|
|
96
|
+
if (lifecycle === "active") await operation();
|
|
97
|
+
});
|
|
98
|
+
operationTail = result.catch(() => undefined);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const stillActive = () => lifecycle === "active";
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
get audioState(): VoiceRouteState { return audio.getRouteState(); },
|
|
106
|
+
get lastError() { return lastError; },
|
|
107
|
+
initialize: () => runActive(async () => {
|
|
108
|
+
if (initialized) return;
|
|
109
|
+
await audio.configure(options.audioMode ?? "offline");
|
|
110
|
+
if (!stillActive()) return;
|
|
111
|
+
await sweepGeneratedAudio();
|
|
112
|
+
if (!stillActive()) return;
|
|
113
|
+
await scheduler.prewarm(["Đổi giao bóng.", "Điểm thắng ván.", "Đổi sân."]);
|
|
114
|
+
if (!stillActive()) return;
|
|
115
|
+
initialized = true;
|
|
116
|
+
notify();
|
|
117
|
+
}),
|
|
118
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
119
|
+
announce(previous, current, event) {
|
|
120
|
+
if (!stillActive()) return;
|
|
121
|
+
void scheduler.enqueue(buildVietnameseAnnouncement(previous, current, event)).catch((error) => {
|
|
122
|
+
lastError = error instanceof Error ? error : new Error("Announcement failed");
|
|
123
|
+
notify();
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
replayAnnouncement: () => stillActive() ? scheduler.replayLast() : Promise.resolve(),
|
|
127
|
+
testSpeaker: () => stillActive() ? scheduler.testSpeaker() : Promise.resolve(),
|
|
128
|
+
configureAudioMode: (mode) => runActive(async () => {
|
|
129
|
+
await audio.configure(mode);
|
|
130
|
+
if (stillActive()) enqueueNotify();
|
|
131
|
+
}),
|
|
132
|
+
dispose() {
|
|
133
|
+
if (disposePromise) return disposePromise;
|
|
134
|
+
lifecycle = "disposing";
|
|
135
|
+
disposed = true;
|
|
136
|
+
unsubscribeAudio?.();
|
|
137
|
+
disposePromise = (async () => {
|
|
138
|
+
await operationTail;
|
|
139
|
+
await scheduler.dispose();
|
|
140
|
+
await notificationQueue;
|
|
141
|
+
listeners.clear();
|
|
142
|
+
lifecycle = "disposed";
|
|
143
|
+
})();
|
|
144
|
+
return disposePromise;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { MatchEvent, MatchSnapshot } from "../contracts";
|
|
2
|
+
|
|
3
|
+
export type AnnouncementKind =
|
|
4
|
+
| "score"
|
|
5
|
+
| "serviceChange"
|
|
6
|
+
| "gamePoint"
|
|
7
|
+
| "gameWon"
|
|
8
|
+
| "courtSwitch"
|
|
9
|
+
| "matchWon";
|
|
10
|
+
|
|
11
|
+
export type FallbackVoiceToken =
|
|
12
|
+
| `number-${number}`
|
|
13
|
+
| "number-mot"
|
|
14
|
+
| "number-lam"
|
|
15
|
+
| `score-deu-${number}`
|
|
16
|
+
| "score-deu-mot"
|
|
17
|
+
| "score-deu-lam"
|
|
18
|
+
| "server-1"
|
|
19
|
+
| "server-2"
|
|
20
|
+
| "court-left"
|
|
21
|
+
| "court-right"
|
|
22
|
+
| "service-change"
|
|
23
|
+
| "game-point"
|
|
24
|
+
| "game-won"
|
|
25
|
+
| "court-switch"
|
|
26
|
+
| "match-won"
|
|
27
|
+
| "test-speaker";
|
|
28
|
+
|
|
29
|
+
export interface AnnouncementPlan {
|
|
30
|
+
readonly kind: AnnouncementKind;
|
|
31
|
+
readonly text: string;
|
|
32
|
+
readonly score?: readonly [number, number];
|
|
33
|
+
readonly fallbackTokens: readonly FallbackVoiceToken[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type VoiceAudioRoute =
|
|
37
|
+
| "system"
|
|
38
|
+
| "bluetoothA2DP"
|
|
39
|
+
| "bluetoothHFP"
|
|
40
|
+
| "speaker"
|
|
41
|
+
| "headphones"
|
|
42
|
+
| "unknown";
|
|
43
|
+
|
|
44
|
+
export type VoiceDisconnectReason =
|
|
45
|
+
| "oldDeviceUnavailable"
|
|
46
|
+
| "becomingNoisy"
|
|
47
|
+
| "unknown";
|
|
48
|
+
|
|
49
|
+
export interface VoiceRouteState {
|
|
50
|
+
readonly route: VoiceAudioRoute;
|
|
51
|
+
readonly interrupted: boolean;
|
|
52
|
+
readonly disconnectedReason: VoiceDisconnectReason | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type VoiceAudioMode = "offline" | "live";
|
|
56
|
+
|
|
57
|
+
export interface AudioPlayback {
|
|
58
|
+
configure(mode: VoiceAudioMode): Promise<void>;
|
|
59
|
+
play(source: string): Promise<void>;
|
|
60
|
+
stop(): Promise<void>;
|
|
61
|
+
dispose(): Promise<void>;
|
|
62
|
+
getRouteState(): VoiceRouteState;
|
|
63
|
+
subscribeToRouteChanges?(listener: (state: VoiceRouteState) => void): () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type NeuralVoiceAvailability = "available" | "unavailable";
|
|
67
|
+
|
|
68
|
+
export interface NeuralVoiceEngine {
|
|
69
|
+
availability(): Promise<NeuralVoiceAvailability>;
|
|
70
|
+
prewarm(commonPhrases: readonly string[]): Promise<void>;
|
|
71
|
+
synthesize(text: string, signal: AbortSignal): Promise<string>;
|
|
72
|
+
cancel(): Promise<void>;
|
|
73
|
+
release?(filePath: string): Promise<void>;
|
|
74
|
+
dispose(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Cloud TTS endpoint the SDK posts announcement text to (see apps/proxy /voice/tts). */
|
|
78
|
+
export interface VoiceTtsOptions {
|
|
79
|
+
readonly url: string;
|
|
80
|
+
/** Extra request headers, e.g. partner bearer tokens. Re-evaluated per request. */
|
|
81
|
+
readonly headers?: () =>
|
|
82
|
+
| Readonly<Record<string, string>>
|
|
83
|
+
| Promise<Readonly<Record<string, string>>>;
|
|
84
|
+
readonly voiceName?: string;
|
|
85
|
+
readonly languageCode?: string;
|
|
86
|
+
readonly requestTimeoutMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type VoiceErrorCode =
|
|
90
|
+
| "engine_unavailable"
|
|
91
|
+
| "network_unavailable"
|
|
92
|
+
| "synthesis_failed"
|
|
93
|
+
| "storage_failure"
|
|
94
|
+
| "playback_failed";
|
|
95
|
+
|
|
96
|
+
export class VoiceError extends Error {
|
|
97
|
+
readonly name = "VoiceError";
|
|
98
|
+
|
|
99
|
+
constructor(
|
|
100
|
+
readonly code: VoiceErrorCode,
|
|
101
|
+
message: string,
|
|
102
|
+
options?: { cause?: unknown },
|
|
103
|
+
) {
|
|
104
|
+
super(message, options);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface MatchVoiceService {
|
|
109
|
+
readonly audioState: VoiceRouteState;
|
|
110
|
+
readonly lastError?: Error | null;
|
|
111
|
+
initialize(): Promise<void>;
|
|
112
|
+
subscribe(listener: () => void): () => void;
|
|
113
|
+
announce(previous: MatchSnapshot, current: MatchSnapshot, event: MatchEvent): void;
|
|
114
|
+
replayAnnouncement(): Promise<void>;
|
|
115
|
+
testSpeaker(): Promise<void>;
|
|
116
|
+
configureAudioMode?(mode: VoiceAudioMode): Promise<void>;
|
|
117
|
+
dispose(): Promise<void>;
|
|
118
|
+
}
|