@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app.plugin.js +6 -0
  5. package/dist/chunk-VHLUJQG5.js +17 -0
  6. package/dist/index.cjs +6284 -0
  7. package/dist/index.d.cts +1436 -0
  8. package/dist/index.d.ts +1436 -0
  9. package/dist/index.js +6186 -0
  10. package/dist/plugin.cjs +153 -0
  11. package/dist/plugin.d.cts +21 -0
  12. package/dist/plugin.d.ts +21 -0
  13. package/dist/plugin.js +131 -0
  14. package/dist/version.cjs +35 -0
  15. package/dist/version.d.cts +4 -0
  16. package/dist/version.d.ts +4 -0
  17. package/dist/version.js +6 -0
  18. package/package.json +126 -0
  19. package/src/contracts.ts +211 -0
  20. package/src/engine.ts +1332 -0
  21. package/src/errors.ts +128 -0
  22. package/src/http-session-provider.ts +259 -0
  23. package/src/index.ts +16 -0
  24. package/src/livekit-room-adapter.ts +353 -0
  25. package/src/livestream-screen.tsx +980 -0
  26. package/src/match/contracts.ts +137 -0
  27. package/src/match/engine.ts +702 -0
  28. package/src/match/persistence/default-store.native.ts +20 -0
  29. package/src/match/persistence/default-store.ts +11 -0
  30. package/src/match/persistence/index.ts +6 -0
  31. package/src/match/persistence/memory-driver.ts +255 -0
  32. package/src/match/persistence/migrations.ts +40 -0
  33. package/src/match/persistence/sqlite-driver.ts +348 -0
  34. package/src/match/persistence/store.ts +497 -0
  35. package/src/match/persistence/types.ts +98 -0
  36. package/src/match/provider.tsx +728 -0
  37. package/src/match/remote/deduplicator.ts +54 -0
  38. package/src/match/remote/encoding.ts +12 -0
  39. package/src/match/remote/hid-gesture.ts +61 -0
  40. package/src/match/remote/index.ts +5 -0
  41. package/src/match/remote/native-service.ts +62 -0
  42. package/src/match/remote/parser.ts +134 -0
  43. package/src/match/remote/types.ts +116 -0
  44. package/src/match/replay.ts +102 -0
  45. package/src/match/state.ts +72 -0
  46. package/src/match/validation.ts +375 -0
  47. package/src/match/voice/assets/court-left.wav +0 -0
  48. package/src/match/voice/assets/court-right.wav +0 -0
  49. package/src/match/voice/assets/court-switch.wav +0 -0
  50. package/src/match/voice/assets/game-point.wav +0 -0
  51. package/src/match/voice/assets/game-won.wav +0 -0
  52. package/src/match/voice/assets/match-won.wav +0 -0
  53. package/src/match/voice/assets/number-0.wav +0 -0
  54. package/src/match/voice/assets/number-1.wav +0 -0
  55. package/src/match/voice/assets/number-10.wav +0 -0
  56. package/src/match/voice/assets/number-11.wav +0 -0
  57. package/src/match/voice/assets/number-12.wav +0 -0
  58. package/src/match/voice/assets/number-13.wav +0 -0
  59. package/src/match/voice/assets/number-14.wav +0 -0
  60. package/src/match/voice/assets/number-15.wav +0 -0
  61. package/src/match/voice/assets/number-16.wav +0 -0
  62. package/src/match/voice/assets/number-17.wav +0 -0
  63. package/src/match/voice/assets/number-18.wav +0 -0
  64. package/src/match/voice/assets/number-19.wav +0 -0
  65. package/src/match/voice/assets/number-2.wav +0 -0
  66. package/src/match/voice/assets/number-20.wav +0 -0
  67. package/src/match/voice/assets/number-3.wav +0 -0
  68. package/src/match/voice/assets/number-30.wav +0 -0
  69. package/src/match/voice/assets/number-4.wav +0 -0
  70. package/src/match/voice/assets/number-40.wav +0 -0
  71. package/src/match/voice/assets/number-5.wav +0 -0
  72. package/src/match/voice/assets/number-50.wav +0 -0
  73. package/src/match/voice/assets/number-6.wav +0 -0
  74. package/src/match/voice/assets/number-60.wav +0 -0
  75. package/src/match/voice/assets/number-7.wav +0 -0
  76. package/src/match/voice/assets/number-70.wav +0 -0
  77. package/src/match/voice/assets/number-8.wav +0 -0
  78. package/src/match/voice/assets/number-80.wav +0 -0
  79. package/src/match/voice/assets/number-9.wav +0 -0
  80. package/src/match/voice/assets/number-90.wav +0 -0
  81. package/src/match/voice/assets/number-lam.wav +0 -0
  82. package/src/match/voice/assets/number-mot.wav +0 -0
  83. package/src/match/voice/assets/score-deu-0.wav +0 -0
  84. package/src/match/voice/assets/score-deu-1.wav +0 -0
  85. package/src/match/voice/assets/score-deu-10.wav +0 -0
  86. package/src/match/voice/assets/score-deu-11.wav +0 -0
  87. package/src/match/voice/assets/score-deu-12.wav +0 -0
  88. package/src/match/voice/assets/score-deu-13.wav +0 -0
  89. package/src/match/voice/assets/score-deu-14.wav +0 -0
  90. package/src/match/voice/assets/score-deu-15.wav +0 -0
  91. package/src/match/voice/assets/score-deu-16.wav +0 -0
  92. package/src/match/voice/assets/score-deu-17.wav +0 -0
  93. package/src/match/voice/assets/score-deu-18.wav +0 -0
  94. package/src/match/voice/assets/score-deu-19.wav +0 -0
  95. package/src/match/voice/assets/score-deu-2.wav +0 -0
  96. package/src/match/voice/assets/score-deu-20.wav +0 -0
  97. package/src/match/voice/assets/score-deu-3.wav +0 -0
  98. package/src/match/voice/assets/score-deu-30.wav +0 -0
  99. package/src/match/voice/assets/score-deu-4.wav +0 -0
  100. package/src/match/voice/assets/score-deu-40.wav +0 -0
  101. package/src/match/voice/assets/score-deu-5.wav +0 -0
  102. package/src/match/voice/assets/score-deu-50.wav +0 -0
  103. package/src/match/voice/assets/score-deu-6.wav +0 -0
  104. package/src/match/voice/assets/score-deu-60.wav +0 -0
  105. package/src/match/voice/assets/score-deu-7.wav +0 -0
  106. package/src/match/voice/assets/score-deu-70.wav +0 -0
  107. package/src/match/voice/assets/score-deu-8.wav +0 -0
  108. package/src/match/voice/assets/score-deu-80.wav +0 -0
  109. package/src/match/voice/assets/score-deu-9.wav +0 -0
  110. package/src/match/voice/assets/score-deu-90.wav +0 -0
  111. package/src/match/voice/assets/score-deu-lam.wav +0 -0
  112. package/src/match/voice/assets/score-deu-mot.wav +0 -0
  113. package/src/match/voice/assets/server-1.wav +0 -0
  114. package/src/match/voice/assets/server-2.wav +0 -0
  115. package/src/match/voice/assets/service-change.wav +0 -0
  116. package/src/match/voice/assets/test-speaker.wav +0 -0
  117. package/src/match/voice/audio.ts +231 -0
  118. package/src/match/voice/bundled-assets.native.ts +47 -0
  119. package/src/match/voice/bundled-assets.ts +3 -0
  120. package/src/match/voice/gemini-engine.ts +167 -0
  121. package/src/match/voice/index.ts +7 -0
  122. package/src/match/voice/phrases.ts +185 -0
  123. package/src/match/voice/scheduler.ts +205 -0
  124. package/src/match/voice/service.ts +147 -0
  125. package/src/match/voice/types.ts +118 -0
  126. package/src/native-runtime.ts +241 -0
  127. package/src/plugin.ts +145 -0
  128. package/src/provider.tsx +176 -0
  129. package/src/version.ts +4 -0
@@ -0,0 +1,231 @@
1
+ import {
2
+ VoiceError,
3
+ type AudioPlayback,
4
+ type VoiceAudioMode,
5
+ type VoiceRouteState,
6
+ } from "./types";
7
+
8
+ export interface ExpoAudioPlayer {
9
+ play(): void;
10
+ pause(): void;
11
+ remove(): void;
12
+ addListener(
13
+ event: "playbackStatusUpdate",
14
+ listener: (status: {
15
+ readonly didJustFinish?: boolean;
16
+ readonly isLoaded?: boolean;
17
+ readonly isBuffering?: boolean;
18
+ readonly error?: unknown;
19
+ readonly playbackState?: string;
20
+ }) => void,
21
+ ): { remove(): void };
22
+ }
23
+
24
+ export interface ExpoAudioBindings {
25
+ setAudioModeAsync(options: {
26
+ readonly allowsRecording: boolean;
27
+ readonly playsInSilentMode: boolean;
28
+ }): Promise<void>;
29
+ createAudioPlayer(source: unknown): ExpoAudioPlayer;
30
+ }
31
+
32
+ export interface ExpoAudioPlaybackOptions {
33
+ readonly load?: () => ExpoAudioBindings;
34
+ readonly loadNativeAudio?: () => NativeAudioSessionBindings;
35
+ readonly resolveSource?: (source: string) => unknown;
36
+ readonly playbackTimeoutMs?: number;
37
+ }
38
+
39
+ export interface NativeAudioSessionBindings {
40
+ captureAudioSessionState(): Promise<void>;
41
+ configureAudioSession(mode: VoiceAudioMode): Promise<VoiceRouteState>;
42
+ getAudioRouteState(): VoiceRouteState | Promise<VoiceRouteState>;
43
+ addListener(
44
+ event: "onAudioRouteChanged",
45
+ listener: (state: VoiceRouteState) => void,
46
+ ): { remove(): void };
47
+ disposeAudioSession(): Promise<void>;
48
+ }
49
+
50
+ declare const require: (name: string) => any;
51
+
52
+ export function createExpoAudioPlayback(
53
+ options: ExpoAudioPlaybackOptions = {},
54
+ ): AudioPlayback {
55
+ const load = options.load ?? (() => require("expo-audio") as ExpoAudioBindings);
56
+ const loadNativeAudio = options.loadNativeAudio ?? (() => {
57
+ const imported = require("@pickleball/voice-native");
58
+ return (imported.default ?? imported) as NativeAudioSessionBindings;
59
+ });
60
+ let player: ExpoAudioPlayer | null = null;
61
+ let settleCurrent: ((error?: unknown) => void) | null = null;
62
+ let nativeAudio: NativeAudioSessionBindings | null | undefined;
63
+ let nativeSubscription: { remove(): void } | null = null;
64
+ let disposed = false;
65
+ let operationGeneration = 0;
66
+ let configureTail: Promise<void> = Promise.resolve();
67
+ let disposePromise: Promise<void> | null = null;
68
+ let routeState: VoiceRouteState = {
69
+ route: "system",
70
+ interrupted: false,
71
+ disconnectedReason: null,
72
+ };
73
+ const listeners = new Set<(state: VoiceRouteState) => void>();
74
+
75
+ const updateRouteState = (state: VoiceRouteState) => {
76
+ if (disposed) return;
77
+ routeState = state;
78
+ for (const listener of listeners) listener(routeState);
79
+ };
80
+
81
+ const getNativeAudio = () => {
82
+ if (nativeAudio !== undefined) return nativeAudio;
83
+ try { nativeAudio = loadNativeAudio(); }
84
+ catch { nativeAudio = null; }
85
+ return nativeAudio;
86
+ };
87
+
88
+ const observeNativeAudio = () => {
89
+ const native = getNativeAudio();
90
+ if (native && !nativeSubscription) {
91
+ nativeSubscription = native.addListener("onAudioRouteChanged", updateRouteState);
92
+ }
93
+ return native;
94
+ };
95
+
96
+ const stop = async () => {
97
+ if (!player) return;
98
+ const current = player;
99
+ player = null;
100
+ const settle = settleCurrent;
101
+ settleCurrent = null;
102
+ try { current.pause(); } catch { /* Already-unloaded players are stopped. */ }
103
+ if (settle) settle();
104
+ else try { current.remove(); } catch { /* Native player teardown is best-effort. */ }
105
+ };
106
+
107
+ const isCurrentAudioOperation = (generation: number) =>
108
+ !disposed && generation === operationGeneration;
109
+
110
+ const releaseNativeAudioSession = async (native: NativeAudioSessionBindings) => {
111
+ nativeSubscription?.remove();
112
+ nativeSubscription = null;
113
+ await native.disposeAudioSession();
114
+ };
115
+
116
+ return {
117
+ configure(mode: VoiceAudioMode) {
118
+ if (disposed) return Promise.resolve();
119
+ const generation = operationGeneration;
120
+ const operation = configureTail.then(async () => {
121
+ if (!isCurrentAudioOperation(generation)) return;
122
+ const native = observeNativeAudio();
123
+ try { await native?.captureAudioSessionState(); }
124
+ catch { /* Capture is best-effort when the optional native module is unavailable. */ }
125
+ if (!isCurrentAudioOperation(generation)) return;
126
+ try {
127
+ await load().setAudioModeAsync({
128
+ allowsRecording: mode === "live",
129
+ playsInSilentMode: true,
130
+ });
131
+ } catch (error) {
132
+ if (native) {
133
+ try { await releaseNativeAudioSession(native); }
134
+ catch { /* Preserve the Expo failure after awaiting best-effort native cleanup. */ }
135
+ }
136
+ throw error;
137
+ }
138
+ if (native) {
139
+ try {
140
+ const nextRouteState = await native.configureAudioSession(mode);
141
+ if (isCurrentAudioOperation(generation)) updateRouteState(nextRouteState);
142
+ } catch (error) {
143
+ try { await releaseNativeAudioSession(native); }
144
+ catch { /* Preserve the handshake failure after awaiting best-effort restoration. */ }
145
+ throw error;
146
+ }
147
+ }
148
+ });
149
+ configureTail = operation.catch(() => undefined);
150
+ return operation;
151
+ },
152
+ async play(source: string) {
153
+ if (disposed) {
154
+ throw new VoiceError("playback_failed", "Audio playback is disposed");
155
+ }
156
+ await stop();
157
+ if (disposed) {
158
+ throw new VoiceError("playback_failed", "Audio playback is disposed");
159
+ }
160
+ let current: ExpoAudioPlayer;
161
+ try {
162
+ current = load().createAudioPlayer(
163
+ options.resolveSource ? options.resolveSource(source) : source,
164
+ );
165
+ } catch (error) {
166
+ throw new VoiceError("playback_failed", "Unable to create audio player", { cause: error });
167
+ }
168
+ player = current;
169
+ await new Promise<void>((resolve, reject) => {
170
+ let settled = false;
171
+ let subscription: { remove(): void } | null = null;
172
+ const timeout = setTimeout(() => {
173
+ settle(new VoiceError("playback_failed", "Audio playback timed out"));
174
+ }, options.playbackTimeoutMs ?? 30_000);
175
+ const settle = (error?: unknown) => {
176
+ if (settled) return;
177
+ settled = true;
178
+ clearTimeout(timeout);
179
+ try { subscription?.remove(); } catch { /* Listener may already be detached. */ }
180
+ if (player === current) player = null;
181
+ if (settleCurrent === settle) settleCurrent = null;
182
+ try { current.remove(); } catch { /* Terminal native state already released it. */ }
183
+ if (error) {
184
+ reject(error instanceof VoiceError
185
+ ? error
186
+ : new VoiceError("playback_failed", "Audio playback failed", { cause: error }));
187
+ } else resolve();
188
+ };
189
+ settleCurrent = settle;
190
+ try {
191
+ subscription = current.addListener("playbackStatusUpdate", (status) => {
192
+ if (status.didJustFinish) settle();
193
+ else if (status.error != null || status.playbackState === "error") {
194
+ settle(status.error ?? new Error("Audio player entered an error state"));
195
+ }
196
+ });
197
+ current.play();
198
+ } catch (error) {
199
+ settle(error);
200
+ }
201
+ });
202
+ },
203
+ stop,
204
+ dispose() {
205
+ if (disposePromise) return disposePromise;
206
+ disposed = true;
207
+ operationGeneration += 1;
208
+ disposePromise = (async () => {
209
+ await stop();
210
+ await configureTail;
211
+ const native = getNativeAudio();
212
+ if (!native) {
213
+ nativeSubscription?.remove();
214
+ nativeSubscription = null;
215
+ }
216
+ try { if (native) await releaseNativeAudioSession(native); }
217
+ catch { /* Native teardown is best-effort. */ }
218
+ listeners.clear();
219
+ })();
220
+ return disposePromise;
221
+ },
222
+ getRouteState() {
223
+ return routeState;
224
+ },
225
+ subscribeToRouteChanges(listener) {
226
+ listeners.add(listener);
227
+ observeNativeAudio();
228
+ return () => listeners.delete(listener);
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,47 @@
1
+ declare const require: (path: string) => number;
2
+
3
+ const assets: Readonly<Record<string, number>> = {
4
+ "number-0": require("./assets/number-0.wav"), "number-1": require("./assets/number-1.wav"),
5
+ "number-2": require("./assets/number-2.wav"), "number-3": require("./assets/number-3.wav"),
6
+ "number-4": require("./assets/number-4.wav"), "number-5": require("./assets/number-5.wav"),
7
+ "number-6": require("./assets/number-6.wav"), "number-7": require("./assets/number-7.wav"),
8
+ "number-8": require("./assets/number-8.wav"), "number-9": require("./assets/number-9.wav"),
9
+ "number-mot": require("./assets/number-mot.wav"), "number-lam": require("./assets/number-lam.wav"),
10
+ "number-10": require("./assets/number-10.wav"), "number-11": require("./assets/number-11.wav"),
11
+ "number-12": require("./assets/number-12.wav"), "number-13": require("./assets/number-13.wav"),
12
+ "number-14": require("./assets/number-14.wav"), "number-15": require("./assets/number-15.wav"),
13
+ "number-16": require("./assets/number-16.wav"), "number-17": require("./assets/number-17.wav"),
14
+ "number-18": require("./assets/number-18.wav"), "number-19": require("./assets/number-19.wav"),
15
+ "number-20": require("./assets/number-20.wav"), "number-30": require("./assets/number-30.wav"),
16
+ "number-40": require("./assets/number-40.wav"), "number-50": require("./assets/number-50.wav"),
17
+ "number-60": require("./assets/number-60.wav"), "number-70": require("./assets/number-70.wav"),
18
+ "number-80": require("./assets/number-80.wav"), "number-90": require("./assets/number-90.wav"),
19
+ "score-deu-0": require("./assets/score-deu-0.wav"), "score-deu-1": require("./assets/score-deu-1.wav"),
20
+ "score-deu-2": require("./assets/score-deu-2.wav"), "score-deu-3": require("./assets/score-deu-3.wav"),
21
+ "score-deu-4": require("./assets/score-deu-4.wav"), "score-deu-5": require("./assets/score-deu-5.wav"),
22
+ "score-deu-6": require("./assets/score-deu-6.wav"), "score-deu-7": require("./assets/score-deu-7.wav"),
23
+ "score-deu-8": require("./assets/score-deu-8.wav"), "score-deu-9": require("./assets/score-deu-9.wav"),
24
+ "score-deu-10": require("./assets/score-deu-10.wav"), "score-deu-11": require("./assets/score-deu-11.wav"),
25
+ "score-deu-12": require("./assets/score-deu-12.wav"), "score-deu-13": require("./assets/score-deu-13.wav"),
26
+ "score-deu-14": require("./assets/score-deu-14.wav"), "score-deu-15": require("./assets/score-deu-15.wav"),
27
+ "score-deu-16": require("./assets/score-deu-16.wav"), "score-deu-17": require("./assets/score-deu-17.wav"),
28
+ "score-deu-18": require("./assets/score-deu-18.wav"), "score-deu-19": require("./assets/score-deu-19.wav"),
29
+ "score-deu-20": require("./assets/score-deu-20.wav"), "score-deu-30": require("./assets/score-deu-30.wav"),
30
+ "score-deu-40": require("./assets/score-deu-40.wav"), "score-deu-50": require("./assets/score-deu-50.wav"),
31
+ "score-deu-60": require("./assets/score-deu-60.wav"), "score-deu-70": require("./assets/score-deu-70.wav"),
32
+ "score-deu-80": require("./assets/score-deu-80.wav"), "score-deu-90": require("./assets/score-deu-90.wav"),
33
+ "score-deu-mot": require("./assets/score-deu-mot.wav"), "score-deu-lam": require("./assets/score-deu-lam.wav"),
34
+ "server-1": require("./assets/server-1.wav"), "server-2": require("./assets/server-2.wav"),
35
+ "court-left": require("./assets/court-left.wav"), "court-right": require("./assets/court-right.wav"),
36
+ "service-change": require("./assets/service-change.wav"), "game-point": require("./assets/game-point.wav"),
37
+ "game-won": require("./assets/game-won.wav"), "court-switch": require("./assets/court-switch.wav"),
38
+ "match-won": require("./assets/match-won.wav"), "test-speaker": require("./assets/test-speaker.wav"),
39
+ };
40
+
41
+ export function resolveBundledVoiceAsset(source: string): string | number {
42
+ const match = /^asset:\/\/voice\/([^/]+)\.wav$/u.exec(source);
43
+ if (!match) return source;
44
+ const asset = assets[match[1]];
45
+ if (asset === undefined) throw new Error(`Unknown bundled voice asset: ${match[1]}`);
46
+ return asset;
47
+ }
@@ -0,0 +1,3 @@
1
+ export function resolveBundledVoiceAsset(source: string): string {
2
+ return source;
3
+ }
@@ -0,0 +1,167 @@
1
+ import {
2
+ VoiceError,
3
+ type NeuralVoiceEngine,
4
+ type VoiceTtsOptions,
5
+ } from "./types";
6
+
7
+ /** Minimal file operations the engine needs; defaults to expo-file-system. */
8
+ export interface GeneratedAudioFileSystem {
9
+ writeFile(path: string, bytes: Uint8Array): Promise<void>;
10
+ removeFile(path: string): Promise<void>;
11
+ }
12
+
13
+ export interface GeminiVoiceEngineOptions extends VoiceTtsOptions {
14
+ /** Directory that receives generated WAV files. */
15
+ readonly generatedRoot: string;
16
+ readonly fetch?: typeof globalThis.fetch;
17
+ readonly fileSystem?: GeneratedAudioFileSystem;
18
+ }
19
+
20
+ declare const require: (name: string) => any;
21
+
22
+ function createExpoGeneratedAudioFileSystem(): GeneratedAudioFileSystem {
23
+ return {
24
+ async writeFile(path, bytes) {
25
+ const fs = require("expo-file-system");
26
+ const file = new fs.File(path);
27
+ if (!file.exists) file.create({ intermediates: true, overwrite: true });
28
+ const handle = file.open(fs.FileMode.ReadWrite);
29
+ try {
30
+ handle.writeBytes(bytes);
31
+ } finally {
32
+ handle.close();
33
+ }
34
+ },
35
+ async removeFile(path) {
36
+ const fs = require("expo-file-system");
37
+ const file = new fs.File(path);
38
+ if (file.exists) file.delete();
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Cloud announcement voice: posts the phrase to the partner proxy, which calls
45
+ * Vertex AI Gemini TTS and returns a WAV body. Failures surface as VoiceError
46
+ * so the scheduler drops to the bundled WAV fallback.
47
+ */
48
+ export function createGeminiVoiceEngine(
49
+ options: GeminiVoiceEngineOptions,
50
+ ): NeuralVoiceEngine {
51
+ const fetcher = options.fetch ?? globalThis.fetch;
52
+ const fileSystem = options.fileSystem ?? createExpoGeneratedAudioFileSystem();
53
+ const timeoutMs = options.requestTimeoutMs ?? 8_000;
54
+ const controllers = new Set<AbortController>();
55
+ let sequence = 0;
56
+ let disposed = false;
57
+
58
+ const abortAll = () => {
59
+ for (const controller of controllers) controller.abort();
60
+ controllers.clear();
61
+ };
62
+
63
+ return {
64
+ async availability() {
65
+ return !disposed && options.url ? "available" : "unavailable";
66
+ },
67
+ async prewarm() {
68
+ // The scheduler drives prewarming through synthesize + its own cache.
69
+ },
70
+ async synthesize(text, signal) {
71
+ if (disposed) {
72
+ throw new VoiceError("engine_unavailable", "Gemini voice engine is disposed");
73
+ }
74
+ const controller = new AbortController();
75
+ controllers.add(controller);
76
+ const onAbort = () => controller.abort();
77
+ signal.addEventListener("abort", onAbort, { once: true });
78
+ if (signal.aborted) controller.abort();
79
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
80
+ try {
81
+ let response: Response;
82
+ try {
83
+ response = await fetcher(options.url, {
84
+ method: "POST",
85
+ headers: {
86
+ "content-type": "application/json",
87
+ ...(options.headers ? await options.headers() : undefined),
88
+ },
89
+ body: JSON.stringify({
90
+ text,
91
+ voiceName: options.voiceName,
92
+ languageCode: options.languageCode,
93
+ }),
94
+ signal: controller.signal,
95
+ });
96
+ } catch (caught) {
97
+ throw new VoiceError(
98
+ controller.signal.aborted ? "synthesis_failed" : "network_unavailable",
99
+ controller.signal.aborted
100
+ ? "Voice synthesis was cancelled"
101
+ : "Voice synthesis request could not reach the TTS endpoint",
102
+ { cause: caught },
103
+ );
104
+ }
105
+ if (!response.ok) {
106
+ throw new VoiceError(
107
+ "synthesis_failed",
108
+ `TTS endpoint responded with status ${response.status}`,
109
+ );
110
+ }
111
+ const bytes = new Uint8Array(await response.arrayBuffer());
112
+ if (bytes.length === 0) {
113
+ throw new VoiceError("synthesis_failed", "TTS endpoint returned an empty body");
114
+ }
115
+ if (controller.signal.aborted || disposed) {
116
+ throw new VoiceError("synthesis_failed", "Voice synthesis was cancelled");
117
+ }
118
+ const path = `${options.generatedRoot}/tts-${++sequence}-${Date.now()}.wav`;
119
+ try {
120
+ await fileSystem.writeFile(path, bytes);
121
+ } catch (caught) {
122
+ throw new VoiceError("storage_failure", "Unable to persist generated audio", {
123
+ cause: caught,
124
+ });
125
+ }
126
+ if (controller.signal.aborted || disposed) {
127
+ await fileSystem.removeFile(path).catch(() => undefined);
128
+ throw new VoiceError("synthesis_failed", "Voice synthesis was cancelled");
129
+ }
130
+ return path;
131
+ } finally {
132
+ clearTimeout(timeout);
133
+ signal.removeEventListener("abort", onAbort);
134
+ controllers.delete(controller);
135
+ }
136
+ },
137
+ async cancel() {
138
+ abortAll();
139
+ },
140
+ async release(filePath) {
141
+ try {
142
+ await fileSystem.removeFile(filePath);
143
+ } catch {
144
+ // Generated-file cleanup is best-effort and must never break scoring.
145
+ }
146
+ },
147
+ async dispose() {
148
+ disposed = true;
149
+ abortAll();
150
+ },
151
+ };
152
+ }
153
+
154
+ /** Placeholder engine used when no TTS endpoint is configured: always falls back. */
155
+ export function createUnavailableVoiceEngine(): NeuralVoiceEngine {
156
+ return {
157
+ async availability() {
158
+ return "unavailable";
159
+ },
160
+ async prewarm() {},
161
+ async synthesize(): Promise<string> {
162
+ throw new VoiceError("engine_unavailable", "No TTS endpoint is configured");
163
+ },
164
+ async cancel() {},
165
+ async dispose() {},
166
+ };
167
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./types";
2
+ export * from "./phrases";
3
+ export * from "./scheduler";
4
+ export * from "./audio";
5
+ export * from "./gemini-engine";
6
+ export * from "./bundled-assets";
7
+ export * from "./service";
@@ -0,0 +1,185 @@
1
+ import type { MatchEvent, MatchSnapshot, MatchTeamId } from "../contracts";
2
+ import type {
3
+ AnnouncementKind,
4
+ AnnouncementPlan,
5
+ FallbackVoiceToken,
6
+ } from "./types";
7
+
8
+ const VIETNAMESE_NUMBERS = [
9
+ "không",
10
+ "một",
11
+ "hai",
12
+ "ba",
13
+ "bốn",
14
+ "năm",
15
+ "sáu",
16
+ "bảy",
17
+ "tám",
18
+ "chín",
19
+ ] as const;
20
+
21
+ function spokenNumber(value: number): string {
22
+ if (!Number.isInteger(value) || value < 0 || value > 99) {
23
+ throw new RangeError("Announcement scores must be integers from 0 to 99");
24
+ }
25
+ if (value < 10) return VIETNAMESE_NUMBERS[value];
26
+ if (value === 10) return "mười";
27
+ const tens = Math.floor(value / 10);
28
+ const units = value % 10;
29
+ const tensWord = tens === 1 ? "mười" : `${VIETNAMESE_NUMBERS[tens]} mươi`;
30
+ if (units === 0) return tensWord;
31
+ if (units === 1) return `${tensWord} mốt`;
32
+ if (units === 5) return `${tensWord} lăm`;
33
+ return `${tensWord} ${VIETNAMESE_NUMBERS[units]}`;
34
+ }
35
+
36
+ function teamName(snapshot: MatchSnapshot, teamId: MatchTeamId): string {
37
+ const team = snapshot.teams[teamId];
38
+ return team.name ?? team.players.map((player) => player.name).join(" và ");
39
+ }
40
+
41
+ function numberTokens(value: number): FallbackVoiceToken[] {
42
+ if (value < 20 || value % 10 === 0) return [`number-${value}`];
43
+ const units = value % 10;
44
+ const unitToken: FallbackVoiceToken = units === 1
45
+ ? "number-mot"
46
+ : units === 5
47
+ ? "number-lam"
48
+ : `number-${units}`;
49
+ return [`number-${Math.floor(value / 10) * 10}`, unitToken];
50
+ }
51
+
52
+ function scoreDeuTokens(value: number): FallbackVoiceToken[] {
53
+ if (value < 20 || value % 10 === 0) return [`score-deu-${value}` as FallbackVoiceToken];
54
+ const units = value % 10;
55
+ const unitToken: FallbackVoiceToken = units === 1
56
+ ? "score-deu-mot"
57
+ : units === 5
58
+ ? "score-deu-lam"
59
+ : (`score-deu-${units}` as FallbackVoiceToken);
60
+ return [`number-${Math.floor(value / 10) * 10}` as FallbackVoiceToken, unitToken];
61
+ }
62
+
63
+ function doublesServerNumber(snapshot: MatchSnapshot): number {
64
+ if (snapshot.scoringMode === "sideOut") {
65
+ return snapshot.serve.serverNumber;
66
+ }
67
+ const servingTeam = snapshot.teams[snapshot.serve.teamId];
68
+ const playerIndex = servingTeam.players.findIndex(
69
+ (player) => player.id === snapshot.serve.playerId,
70
+ );
71
+ return playerIndex === 1 ? 2 : 1;
72
+ }
73
+
74
+ function scorePlan(snapshot: MatchSnapshot, prefix: string = ""): AnnouncementPlan {
75
+ const serving = snapshot.serve.teamId;
76
+ const receiving = serving === "A" ? "B" : "A";
77
+ const game = snapshot.games[snapshot.currentGame - 1];
78
+ if (!game) throw new TypeError("Current game is missing");
79
+ const servingScore = game.score[serving];
80
+ const receivingScore = game.score[receiving];
81
+ const serverNum = snapshot.format === "doubles" ? doublesServerNumber(snapshot) : 1;
82
+ const server = snapshot.format === "doubles" ? ` ${spokenNumber(serverNum)}` : "";
83
+
84
+ const fallbackTokens: FallbackVoiceToken[] = [];
85
+ if (prefix === "Đổi giao bóng") {
86
+ fallbackTokens.push("service-change");
87
+ } else if (prefix === "Điểm thắng ván") {
88
+ fallbackTokens.push("game-point");
89
+ }
90
+
91
+ if (servingScore === receivingScore) {
92
+ fallbackTokens.push(...scoreDeuTokens(servingScore));
93
+ } else {
94
+ fallbackTokens.push(...numberTokens(servingScore));
95
+ fallbackTokens.push(...numberTokens(receivingScore));
96
+ }
97
+
98
+ if (snapshot.format === "doubles") {
99
+ fallbackTokens.push(`server-${serverNum}` as FallbackVoiceToken);
100
+ }
101
+
102
+ const secondPart = servingScore === receivingScore ? "đều" : spokenNumber(receivingScore);
103
+ const scoreBody = `${spokenNumber(servingScore).replace(/^./u, (letter) => letter.toLocaleUpperCase("vi"))} ${secondPart}${server}.`;
104
+ return {
105
+ kind: "score",
106
+ text: prefix
107
+ ? `${prefix}, ${scoreBody.replace(/^./u, (letter) => letter.toLocaleLowerCase("vi"))}`
108
+ : scoreBody,
109
+ score: [servingScore, receivingScore],
110
+ fallbackTokens,
111
+ };
112
+ }
113
+
114
+ function statusPlan(
115
+ kind: AnnouncementKind,
116
+ text: string,
117
+ token: FallbackVoiceToken,
118
+ ): AnnouncementPlan {
119
+ return { kind, text, fallbackTokens: [token] };
120
+ }
121
+
122
+ /** Pure phrase selection from the state before and after one committed event. */
123
+ export function buildVietnameseAnnouncement(
124
+ previous: MatchSnapshot,
125
+ current: MatchSnapshot,
126
+ event: MatchEvent,
127
+ ): AnnouncementPlan {
128
+ if (current.status === "completed" && previous.status !== "completed" && current.winner) {
129
+ return statusPlan(
130
+ "matchWon",
131
+ `Đội ${teamName(current, current.winner)} thắng trận.`,
132
+ "match-won",
133
+ );
134
+ }
135
+ const previousGame = previous.games[previous.currentGame - 1];
136
+ const currentGame = current.games[current.currentGame - 1];
137
+ if (currentGame?.winner && !previousGame?.winner) {
138
+ return statusPlan(
139
+ "gameWon",
140
+ `Đội ${teamName(current, currentGame.winner)} thắng ván.`,
141
+ "game-won",
142
+ );
143
+ }
144
+ if (event.type === "courtSwitchConfirmed") {
145
+ return statusPlan("courtSwitch", "Đổi sân.", "court-switch");
146
+ }
147
+ const serviceChanged = previous.serve.teamId !== current.serve.teamId;
148
+ if (serviceChanged) {
149
+ const plan = scorePlan(current, "Đổi giao bóng");
150
+ return { ...plan, kind: "serviceChange" };
151
+ }
152
+ if (
153
+ currentGame &&
154
+ !currentGame.winner &&
155
+ Math.max(currentGame.score.A, currentGame.score.B) === current.targetScore - 1
156
+ ) {
157
+ const plan = scorePlan(current, "Điểm thắng ván");
158
+ return { ...plan, kind: "gamePoint" };
159
+ }
160
+ return scorePlan(current);
161
+ }
162
+
163
+ export function planBundledFallback(
164
+ plan: AnnouncementPlan,
165
+ ): readonly string[] {
166
+ let tokens = plan.fallbackTokens;
167
+ if (plan.score) {
168
+ const prefixTokens = plan.fallbackTokens.filter(
169
+ (token) => token === "service-change" || token === "game-point",
170
+ );
171
+ const suffixTokens = plan.fallbackTokens.filter(
172
+ (token) => token.startsWith("server-") || token.startsWith("court-"),
173
+ );
174
+ const scoreTokens: FallbackVoiceToken[] =
175
+ plan.score[0] === plan.score[1]
176
+ ? [...scoreDeuTokens(plan.score[0])]
177
+ : [...numberTokens(plan.score[0]), ...numberTokens(plan.score[1])];
178
+ tokens = [
179
+ ...prefixTokens,
180
+ ...scoreTokens,
181
+ ...suffixTokens,
182
+ ];
183
+ }
184
+ return tokens.map((token) => `asset://voice/${token}.wav`);
185
+ }