@earendil-works/pi-voice 0.1.0

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +69 -0
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio.ts +193 -0
  9. package/src/catalog.generated.ts +1305 -0
  10. package/src/catalog.ts +89 -0
  11. package/src/chinese.ts +52 -0
  12. package/src/deferred.ts +30 -0
  13. package/src/dictation-controller.ts +204 -0
  14. package/src/file-audio.ts +164 -0
  15. package/src/file-transcription.ts +212 -0
  16. package/src/index.ts +114 -0
  17. package/src/install-migration.ts +47 -0
  18. package/src/keybindings.ts +118 -0
  19. package/src/languages.ts +22 -0
  20. package/src/microphone-picker.ts +99 -0
  21. package/src/model-activation.ts +74 -0
  22. package/src/model-cells.ts +218 -0
  23. package/src/model-picker.ts +1026 -0
  24. package/src/model-ratings-help.md +41 -0
  25. package/src/model-ratings-help.ts +146 -0
  26. package/src/model-selection-controller.ts +185 -0
  27. package/src/models.ts +263 -0
  28. package/src/onboarding.ts +314 -0
  29. package/src/pcm-chunker.ts +42 -0
  30. package/src/pcm.ts +19 -0
  31. package/src/recommendation-picker.ts +512 -0
  32. package/src/recommendations.ts +423 -0
  33. package/src/runtime.ts +501 -0
  34. package/src/settings-menu.ts +410 -0
  35. package/src/settings-path.ts +13 -0
  36. package/src/settings.ts +235 -0
  37. package/src/shortcut-core.ts +85 -0
  38. package/src/shortcuts.ts +167 -0
  39. package/src/startup-shortcut.ts +24 -0
  40. package/src/transcript-preview.ts +52 -0
  41. package/src/transcription-service.ts +548 -0
  42. package/src/transcription.ts +186 -0
  43. package/src/try-it.ts +327 -0
  44. package/src/ui-components.ts +432 -0
  45. package/src/visualizer.ts +269 -0
package/src/catalog.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { CATALOG_MODELS_GENERATED } from "./catalog.generated.js";
2
+ import { canonicalLanguage, languageIdentity, resolveModelLanguage } from "./languages.js";
3
+ export { canonicalLanguage, languageIdentity, resolveModelLanguage } from "./languages.js";
4
+
5
+ export type CatalogModel = {
6
+ readonly id: string;
7
+ readonly name: string;
8
+ readonly description: string;
9
+ readonly repository: string;
10
+ readonly revision: string;
11
+ readonly license: string;
12
+ readonly family: string;
13
+ readonly parameters: string | null;
14
+ readonly languages: readonly string[];
15
+ readonly capabilities: {
16
+ readonly streaming: boolean;
17
+ readonly translate: boolean;
18
+ readonly languageDetection: boolean;
19
+ };
20
+ readonly quant: string;
21
+ readonly filename: string;
22
+ readonly size: number;
23
+ readonly sha256: string;
24
+ };
25
+
26
+ export const CATALOG_MODELS: readonly CatalogModel[] = CATALOG_MODELS_GENERATED;
27
+
28
+ const languageNames = new Intl.DisplayNames(["en"], { type: "language" });
29
+
30
+ export function displayLanguage(language: string): string {
31
+ // ASR catalogs conventionally use zh for Mandarin and list Cantonese as yue.
32
+ // Make that spoken-language distinction explicit without losing the familiar
33
+ // umbrella term users look for.
34
+ if (canonicalLanguage(language) === "zh") return "Mandarin (Chinese)";
35
+ try {
36
+ return languageNames.of(language) ?? language;
37
+ } catch {
38
+ return language;
39
+ }
40
+ }
41
+
42
+ export function modelMatchesLanguage(model: CatalogModel, language: string): boolean {
43
+ return resolveModelLanguage(model, language) !== undefined;
44
+ }
45
+
46
+ function preferredLanguageMatchCount(
47
+ model: CatalogModel,
48
+ preferredLanguages: readonly string[],
49
+ ): number {
50
+ return [...new Set(preferredLanguages.map(languageIdentity))].filter((language) =>
51
+ modelMatchesLanguage(model, language),
52
+ ).length;
53
+ }
54
+
55
+ // The catalog carries no editorial rank or score: the pickers order measured
56
+ // models by benchmark, so names only settle models without one.
57
+ export function rankCatalogModels(
58
+ models: readonly CatalogModel[],
59
+ preferredLanguages: readonly string[] = [],
60
+ isDownloaded: (model: CatalogModel) => boolean = () => false,
61
+ ): CatalogModel[] {
62
+ return [...models].sort(
63
+ (left, right) =>
64
+ preferredLanguageMatchCount(right, preferredLanguages) -
65
+ preferredLanguageMatchCount(left, preferredLanguages) ||
66
+ Number(isDownloaded(right)) - Number(isDownloaded(left)) ||
67
+ left.name.localeCompare(right.name),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * What a catalog search runs against. Languages and capabilities are left
73
+ * out on purpose: the pickers already scope and grade by language, and a
74
+ * long haystack made short queries match nearly everything.
75
+ */
76
+ export function catalogModelSearchText(model: CatalogModel): string {
77
+ return [model.id, model.name, model.family, model.parameters ?? ""].join(" ").toLowerCase();
78
+ }
79
+
80
+ export function getCatalogModel(id: string): CatalogModel | undefined {
81
+ return CATALOG_MODELS.find((model) => model.id === id);
82
+ }
83
+
84
+ export function formatBinarySize(bytes: number): string {
85
+ if (bytes >= 1024 ** 3) {
86
+ return `${(bytes / 1024 ** 3).toFixed(bytes >= 10 * 1024 ** 3 ? 0 : 1)} GiB`;
87
+ }
88
+ return `${Math.round(bytes / (1024 * 1024))} MiB`;
89
+ }
package/src/chinese.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ChineseOutput } from "./settings.js";
2
+
3
+ type Converter = (text: string) => string;
4
+
5
+ const converters = new Map<ChineseOutput, Promise<Converter>>();
6
+
7
+ async function createConverter(output: ChineseOutput): Promise<Converter> {
8
+ const { default: OpenCC } = await import("opencc-js");
9
+ switch (output) {
10
+ case "simplified":
11
+ return OpenCC.Converter({ from: "t", to: "cn" });
12
+ case "traditional-taiwan":
13
+ return OpenCC.Converter({ from: "cn", to: "tw" });
14
+ case "traditional-hong-kong":
15
+ return OpenCC.Converter({ from: "cn", to: "hk" });
16
+ }
17
+ }
18
+
19
+ function converterFor(output: ChineseOutput): Promise<Converter> {
20
+ const existing = converters.get(output);
21
+ if (existing) return existing;
22
+
23
+ const loading = createConverter(output);
24
+ converters.set(output, loading);
25
+ void loading.catch(() => {
26
+ if (converters.get(output) === loading) converters.delete(output);
27
+ });
28
+ return loading;
29
+ }
30
+
31
+ export function isChineseLanguage(language: string): boolean {
32
+ const base = language.toLowerCase().split("-", 1)[0];
33
+ return base === "zh" || base === "yue";
34
+ }
35
+
36
+ export async function convertChineseOutput(
37
+ text: string,
38
+ output: ChineseOutput,
39
+ ): Promise<string> {
40
+ return (await converterFor(output))(text);
41
+ }
42
+
43
+ export function chineseOutputSummary(output: ChineseOutput): string {
44
+ switch (output) {
45
+ case "simplified":
46
+ return "Simplified";
47
+ case "traditional-taiwan":
48
+ return "Traditional (Taiwan)";
49
+ case "traditional-hong-kong":
50
+ return "Traditional (Hong Kong)";
51
+ }
52
+ }
@@ -0,0 +1,30 @@
1
+ /** A promise with external settle controls. Settling is idempotent. */
2
+ export class Deferred<T = void> {
3
+ readonly promise: Promise<T>;
4
+ private resolvePromise!: (value: T) => void;
5
+ private rejectPromise!: (error: unknown) => void;
6
+ private done = false;
7
+
8
+ constructor() {
9
+ this.promise = new Promise<T>((resolve, reject) => {
10
+ this.resolvePromise = resolve;
11
+ this.rejectPromise = reject;
12
+ });
13
+ }
14
+
15
+ get settled(): boolean {
16
+ return this.done;
17
+ }
18
+
19
+ resolve(value: T): void {
20
+ if (this.done) return;
21
+ this.done = true;
22
+ this.resolvePromise(value);
23
+ }
24
+
25
+ reject(error: unknown): void {
26
+ if (this.done) return;
27
+ this.done = true;
28
+ this.rejectPromise(error);
29
+ }
30
+ }
@@ -0,0 +1,204 @@
1
+ import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
2
+ import { PcmChunker } from "./pcm-chunker.js";
3
+ import type { MicrophoneSetting, TranscribeSettings } from "./settings.js";
4
+ import type { DictationReservation, TranscriptionService } from "./transcription-service.js";
5
+
6
+ export type DictationCapture = {
7
+ onFrame?: (frame: Int16Array) => void;
8
+ start(): void;
9
+ stop(): Promise<{ pcm: Float32Array }>;
10
+ };
11
+ export type DictationResult = { text: string; speechSeconds: number; transcribeSeconds: number };
12
+ export type DictationState =
13
+ | { phase: "idle" | "ready" | "starting" | "listening" | "transcribing" | "cancelling" | "disposed" }
14
+ | { phase: "result"; result: DictationResult }
15
+ | { phase: "error"; stage: "model" | "capture" | "transcription"; cause: unknown };
16
+ export type DictationControllerOptions = {
17
+ createCapture: (microphone: MicrophoneSetting) => DictationCapture;
18
+ now?: () => number;
19
+ onChange?: (state: DictationState) => void;
20
+ onFrame?: (frame: Int16Array) => void;
21
+ };
22
+ type Take = {
23
+ settings: TranscribeSettings;
24
+ reservation: DictationReservation;
25
+ abort: AbortController;
26
+ chunker: PcmChunker;
27
+ capture?: DictationCapture;
28
+ stopping?: Promise<{ pcm: Float32Array }>;
29
+ submission?: Promise<DictationResult | undefined>;
30
+ };
31
+
32
+ /** Owns one capture/reservation lifecycle, never the injected service itself. */
33
+ export class DictationController {
34
+ private current: DictationState = { phase: "idle" };
35
+ private take: Take | undefined;
36
+ private disposed = false;
37
+ private cleanup: Promise<void> = Promise.resolve();
38
+ private starting: Promise<void> | undefined;
39
+ private startedAt = 0;
40
+ private readonly now: () => number;
41
+ private readiness: "loading" | "ready" | "failed" = "loading";
42
+
43
+ constructor(
44
+ private readonly service: Pick<TranscriptionService, "reserveDictation">,
45
+ private readonly options: DictationControllerOptions,
46
+ ) {
47
+ this.now = options.now ?? (() => performance.now());
48
+ }
49
+
50
+ get state(): DictationState { return this.current; }
51
+ get modelState(): "loading" | "ready" | "failed" { return this.readiness; }
52
+ get elapsedMs(): number { return Math.max(0, this.now() - this.startedAt); }
53
+
54
+ private notify(): void {
55
+ if (this.disposed) return;
56
+ // Presentation must not strand the reservation or drop recorded audio.
57
+ try { this.options.onChange?.(this.current); } catch { /* UI owns rendering errors. */ }
58
+ }
59
+ private setState(state: DictationState): void {
60
+ if (this.disposed) return;
61
+ this.current = state;
62
+ this.notify();
63
+ }
64
+
65
+ /** Optional prewarming. Model preparation overlaps with reading or recording. */
66
+ prepare(settings: TranscribeSettings): void {
67
+ if (this.disposed || ["starting", "listening", "transcribing", "cancelling"].includes(this.current.phase)) return;
68
+ if (this.take?.settings === settings && this.readiness !== "failed") return;
69
+ this.take?.reservation.cancel();
70
+ this.take = undefined;
71
+ this.readiness = "loading";
72
+ let reservation: DictationReservation;
73
+ try {
74
+ reservation = this.service.reserveDictation(settings);
75
+ } catch (cause) {
76
+ this.readiness = "failed";
77
+ this.setState({ phase: "error", stage: "model", cause });
78
+ return;
79
+ }
80
+ const take: Take = {
81
+ settings, reservation, abort: new AbortController(),
82
+ chunker: new PcmChunker((chunk) => reservation.feed(chunk)),
83
+ };
84
+ this.take = take;
85
+ this.setState({ phase: "ready" });
86
+ void reservation.ready.then(
87
+ () => {
88
+ if (this.disposed || this.take !== take) return;
89
+ this.readiness = "ready";
90
+ this.notify();
91
+ },
92
+ (cause: unknown) => {
93
+ if (this.disposed || this.take !== take) return;
94
+ this.readiness = "failed";
95
+ if (this.current.phase === "ready") this.setState({ phase: "error", stage: "model", cause });
96
+ else this.notify(); // Keep capturing; submission will report the error.
97
+ },
98
+ );
99
+ }
100
+
101
+ start(settings: TranscribeSettings): Promise<void> {
102
+ if (this.disposed) return Promise.resolve();
103
+ if (this.current.phase === "starting") return this.starting ?? Promise.resolve();
104
+ if (["listening", "transcribing", "cancelling"].includes(this.current.phase)) return Promise.resolve();
105
+ this.prepare(settings);
106
+ const take = this.take;
107
+ if (!take) return Promise.resolve();
108
+ this.setState({ phase: "starting" });
109
+ const work = this.cleanup.then(() => {
110
+ if (this.disposed || this.take !== take) return;
111
+ try {
112
+ const capture = this.options.createCapture(settings.microphone);
113
+ capture.onFrame = (frame) => {
114
+ if (this.disposed || this.take !== take || take.abort.signal.aborted) return;
115
+ take.chunker.push(frame);
116
+ try { this.options.onFrame?.(frame); } catch { /* Audio is already fed. */ }
117
+ };
118
+ capture.start();
119
+ take.capture = capture;
120
+ this.startedAt = this.now();
121
+ this.setState({ phase: "listening" });
122
+ } catch (cause) {
123
+ this.take = undefined;
124
+ take.chunker.discard();
125
+ take.reservation.cancel();
126
+ this.setState({ phase: "error", stage: "capture", cause });
127
+ }
128
+ });
129
+ this.starting = work;
130
+ return work;
131
+ }
132
+
133
+ private stopCapture(take: Take): Promise<{ pcm: Float32Array }> {
134
+ if (take.stopping) return take.stopping;
135
+ const capture = take.capture;
136
+ take.capture = undefined;
137
+ if (!capture) return Promise.resolve({ pcm: new Float32Array() });
138
+ capture.onFrame = undefined;
139
+ // Normalize synchronous failures too; native implementations normally reject.
140
+ try { take.stopping = capture.stop(); }
141
+ catch (error) { take.stopping = Promise.reject(error); }
142
+ return take.stopping;
143
+ }
144
+
145
+ stop(): Promise<DictationResult | undefined> {
146
+ const take = this.take;
147
+ if (!take || this.disposed) return Promise.resolve(undefined);
148
+ if (take.submission) return take.submission;
149
+ if (this.current.phase !== "listening") return Promise.resolve(undefined);
150
+ const stoppedAt = this.now();
151
+ this.setState({ phase: "transcribing" });
152
+ let stage: "capture" | "transcription" = "capture";
153
+ take.submission = this.stopCapture(take).then(async ({ pcm }) => {
154
+ // Cancellation while the native microphone is stopping must never submit.
155
+ if (this.take !== take || take.abort.signal.aborted) return undefined;
156
+ take.chunker.flush();
157
+ stage = "transcription";
158
+ const text = await take.reservation.submit(pcm, take.abort.signal);
159
+ if (this.disposed || this.take !== take || take.abort.signal.aborted) return undefined;
160
+ const result = {
161
+ text,
162
+ speechSeconds: pcm.length / CAPTURE_SAMPLE_RATE,
163
+ transcribeSeconds: Math.max(0, (this.now() - stoppedAt) / 1000),
164
+ };
165
+ this.take = undefined;
166
+ this.setState({ phase: "result", result });
167
+ return result;
168
+ }).catch((cause: unknown) => {
169
+ take.reservation.cancel(); // Releases the lane if stop failed before submit.
170
+ if (this.disposed || this.take !== take || take.abort.signal.aborted) return undefined;
171
+ this.take = undefined;
172
+ this.setState({ phase: "error", stage, cause });
173
+ return undefined;
174
+ });
175
+ return take.submission;
176
+ }
177
+
178
+ /** Cancel is serialized with capture teardown, so retries never overlap devices. */
179
+ cancel(): Promise<void> {
180
+ const take = this.take;
181
+ this.take = undefined; // Invalidate callbacks before touching native resources.
182
+ if (!take) return this.cleanup;
183
+ take.abort.abort();
184
+ take.chunker.discard();
185
+ take.reservation.cancel();
186
+ this.setState({ phase: "cancelling" });
187
+ const cleanup = Promise.all([
188
+ this.cleanup,
189
+ this.stopCapture(take).catch(() => undefined),
190
+ take.submission,
191
+ this.starting,
192
+ ]).then(() => {
193
+ if (this.cleanup === cleanup) this.setState({ phase: "idle" });
194
+ });
195
+ this.cleanup = cleanup;
196
+ return cleanup;
197
+ }
198
+
199
+ dispose(): Promise<void> {
200
+ this.disposed = true;
201
+ this.current = { phase: "disposed" };
202
+ return this.cancel();
203
+ }
204
+ }
@@ -0,0 +1,164 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const FILE_SAMPLE_RATE = 16_000;
4
+
5
+ const BYTES_PER_SAMPLE = Float32Array.BYTES_PER_ELEMENT;
6
+ const MAX_DECODED_BYTES = 128 * 1024 * 1024;
7
+ const MAX_STDERR_CHARS = 8 * 1024;
8
+
9
+ type FfmpegConfiguration = {
10
+ executable: string;
11
+ variable?: "PI_VOICE_FFMPEG_PATH" | "PI_TRANSCRIBE_FFMPEG_PATH";
12
+ };
13
+
14
+ function ffmpegConfiguration(): FfmpegConfiguration {
15
+ const current = process.env.PI_VOICE_FFMPEG_PATH?.trim();
16
+ if (current) return { executable: current, variable: "PI_VOICE_FFMPEG_PATH" };
17
+ const legacy = process.env.PI_TRANSCRIBE_FFMPEG_PATH?.trim();
18
+ if (legacy) return { executable: legacy, variable: "PI_TRANSCRIBE_FFMPEG_PATH" };
19
+ return { executable: "ffmpeg" };
20
+ }
21
+
22
+ function installHint(): string {
23
+ switch (process.platform) {
24
+ case "darwin":
25
+ return "On macOS with Homebrew: brew install ffmpeg";
26
+ case "win32":
27
+ return "On Windows: winget install Gyan.FFmpeg";
28
+ default:
29
+ return "On Debian/Ubuntu: sudo apt install ffmpeg (use your distribution's package manager elsewhere)";
30
+ }
31
+ }
32
+
33
+ function missingFfmpegError(configuration: FfmpegConfiguration): Error {
34
+ const locationHelp = configuration.variable
35
+ ? `The configured ${configuration.variable} (${configuration.executable}) could not be found. Correct it or unset it to use PATH.`
36
+ : "The ffmpeg executable was not found on PATH.";
37
+ return new Error(
38
+ [
39
+ "File transcription requires FFmpeg.",
40
+ locationHelp,
41
+ installHint(),
42
+ "Explain this requirement to the user and ask permission before installing system software, then retry transcribe_file.",
43
+ ].join(" "),
44
+ );
45
+ }
46
+
47
+ function abortReason(signal: AbortSignal): Error {
48
+ return signal.reason instanceof Error
49
+ ? signal.reason
50
+ : new Error("File audio decoding was cancelled");
51
+ }
52
+
53
+ export type DecodedFileAudio = {
54
+ pcm: Float32Array;
55
+ seconds: number;
56
+ };
57
+
58
+ /** Decode the first audio stream in a local media file to transcribe.cpp PCM. */
59
+ export async function decodeFileAudio(
60
+ path: string,
61
+ signal?: AbortSignal,
62
+ ): Promise<DecodedFileAudio> {
63
+ signal?.throwIfAborted();
64
+ const configuration = ffmpegConfiguration();
65
+ const executable = configuration.executable;
66
+ const child = spawn(
67
+ executable,
68
+ [
69
+ "-hide_banner",
70
+ "-loglevel",
71
+ "error",
72
+ "-nostdin",
73
+ "-protocol_whitelist",
74
+ "file,pipe",
75
+ "-i",
76
+ path,
77
+ "-map",
78
+ "0:a:0",
79
+ "-vn",
80
+ "-sn",
81
+ "-dn",
82
+ "-ac",
83
+ "1",
84
+ "-ar",
85
+ String(FILE_SAMPLE_RATE),
86
+ "-acodec",
87
+ "pcm_f32le",
88
+ "-f",
89
+ "f32le",
90
+ "pipe:1",
91
+ ],
92
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
93
+ );
94
+
95
+ return new Promise<DecodedFileAudio>((resolve, reject) => {
96
+ const chunks: Buffer[] = [];
97
+ let byteLength = 0;
98
+ let stderr = "";
99
+ let terminalError: Error | undefined;
100
+
101
+ const onAbort = (): void => {
102
+ terminalError = abortReason(signal!);
103
+ child.kill("SIGTERM");
104
+ };
105
+ signal?.addEventListener("abort", onAbort, { once: true });
106
+
107
+ child.stdout.on("data", (chunk: Buffer) => {
108
+ if (terminalError) return;
109
+ byteLength += chunk.length;
110
+ if (byteLength > MAX_DECODED_BYTES) {
111
+ terminalError = new Error(
112
+ "Decoded audio exceeds the 128 MiB safety limit (about 35 minutes at 16 kHz mono). Split the media file into smaller parts and retry.",
113
+ );
114
+ child.kill("SIGTERM");
115
+ return;
116
+ }
117
+ chunks.push(chunk);
118
+ });
119
+
120
+ child.stderr.on("data", (chunk: Buffer) => {
121
+ stderr += chunk.toString("utf8");
122
+ if (stderr.length > MAX_STDERR_CHARS) stderr = stderr.slice(-MAX_STDERR_CHARS);
123
+ });
124
+
125
+ child.once("error", (error: NodeJS.ErrnoException) => {
126
+ terminalError =
127
+ error.code === "ENOENT"
128
+ ? missingFfmpegError(configuration)
129
+ : new Error(`Could not start FFmpeg (${executable}): ${error.message}`);
130
+ });
131
+
132
+ child.once("close", (code, closeSignal) => {
133
+ signal?.removeEventListener("abort", onAbort);
134
+ if (terminalError) {
135
+ reject(terminalError);
136
+ return;
137
+ }
138
+ if (code !== 0) {
139
+ const detail = stderr.trim() || `process exited with code ${code ?? "unknown"}`;
140
+ reject(
141
+ new Error(
142
+ `FFmpeg could not decode the file${closeSignal ? ` (signal ${closeSignal})` : ""}: ${detail}`,
143
+ ),
144
+ );
145
+ return;
146
+ }
147
+ if (byteLength === 0) {
148
+ reject(new Error("FFmpeg decoded no audio samples; the file may not contain an audio stream"));
149
+ return;
150
+ }
151
+ if (byteLength % BYTES_PER_SAMPLE !== 0) {
152
+ reject(new Error(`FFmpeg returned ${byteLength} bytes of incomplete float32 PCM`));
153
+ return;
154
+ }
155
+
156
+ const output = Buffer.concat(chunks, byteLength);
157
+ const pcm =
158
+ output.byteOffset % BYTES_PER_SAMPLE === 0
159
+ ? new Float32Array(output.buffer, output.byteOffset, output.byteLength / BYTES_PER_SAMPLE)
160
+ : new Float32Array(Uint8Array.from(output).buffer);
161
+ resolve({ pcm, seconds: pcm.length / FILE_SAMPLE_RATE });
162
+ });
163
+ });
164
+ }