Compare commits
6 Commits
3616592896
...
dev-whispe
Author | SHA1 | Date | |
---|---|---|---|
0ba5c4b309 | |||
e61fb43ee3 | |||
123933d459 | |||
f0a722b3fb | |||
dca3987e18 | |||
8f67d0421b |
1
.gitignore
vendored
1
.gitignore
vendored
@ -36,3 +36,4 @@ yarn-error.*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
coverage/**/*
|
coverage/**/*
|
||||||
|
assets/whisper
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
export default {
|
export default {
|
||||||
getDb: jest.fn(() => {
|
getDb: jest.fn(() => {
|
||||||
return {
|
return {
|
||||||
runAsync: jest.fn((statement: string, value: string) => {}),
|
runAsync: jest.fn((statement: string, ... values: string []) => {}),
|
||||||
|
runSync: jest.fn((statement: string, ... values : string []) => {}),
|
||||||
getFirstAsync: jest.fn((statement: string, value: string) => {
|
getFirstAsync: jest.fn((statement: string, value: string) => {
|
||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
|
2
android/app/proguard-rules.pro
vendored
2
android/app/proguard-rules.pro
vendored
@ -12,3 +12,5 @@
|
|||||||
-keep class com.facebook.react.turbomodule.** { *; }
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
# Add any project specific keep options here:
|
# Add any project specific keep options here:
|
||||||
|
# whisper.rn
|
||||||
|
-keep class com.rnwhisper.** { *; }
|
@ -3,5 +3,5 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
<application android:largeHeap="true" android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
@ -13,7 +15,7 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:largeHeap="true">
|
||||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
3
app.json
3
app.json
@ -57,7 +57,8 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-audio"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
118
app/i18n/api.ts
118
app/i18n/api.ts
@ -1,6 +1,6 @@
|
|||||||
import { Cache } from "react-native-cache";
|
import { Cache } from "react-native-cache";
|
||||||
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
|
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Settings } from "../lib/settings";
|
import { Settings } from "../lib/settings";
|
||||||
|
|
||||||
type language_t = string;
|
type language_t = string;
|
||||||
@ -9,74 +9,91 @@ const cache = new Cache({
|
|||||||
namespace: "translation_terrace",
|
namespace: "translation_terrace",
|
||||||
policy: {
|
policy: {
|
||||||
maxEntries: 50000, // if unspecified, it can have unlimited entries
|
maxEntries: 50000, // if unspecified, it can have unlimited entries
|
||||||
stdTTL: 0 // the standard ttl as number in seconds, default: 0 (unlimited)
|
stdTTL: 0, // the standard ttl as number in seconds, default: 0 (unlimited)
|
||||||
},
|
},
|
||||||
backend: AsyncStorage
|
backend: AsyncStorage,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type language_matrix_entry = {
|
export type language_matrix_entry = {
|
||||||
code: string,
|
code: string;
|
||||||
name: string,
|
name: string;
|
||||||
targets: string []
|
targets: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type language_matrix = {
|
export type language_matrix = {
|
||||||
[key:string] : language_matrix_entry
|
[key: string]: language_matrix_entry;
|
||||||
}
|
};
|
||||||
|
|
||||||
export async function fetchWithTimeout(url : string, options : RequestInit, timeout = 5000) : Promise<Response> {
|
export async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
timeout = 5000
|
||||||
|
): Promise<Response> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
fetch(url, options),
|
fetch(url, options),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("timeout")), timeout)
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LanguageServer {
|
export class LanguageServer {
|
||||||
constructor(public baseUrl : string) {}
|
constructor(public baseUrl: string) {}
|
||||||
|
|
||||||
async fetchLanguages(timeout = 500) : Promise<language_matrix> {
|
async fetchLanguages(timeout = 500): Promise<language_matrix> {
|
||||||
let data = {};
|
let data = {};
|
||||||
const res = await fetchWithTimeout(this.baseUrl + "/languages", {
|
const res = await fetchWithTimeout(
|
||||||
|
this.baseUrl + "/languages",
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
}, timeout);
|
},
|
||||||
|
timeout
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Parsing data from ${await res.text()}: ${e}`)
|
throw new Error(`Parsing data from ${await res.text()}: ${e}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.values(data as language_matrix_entry []).map((obj : language_matrix_entry) => {
|
Object.values(data as language_matrix_entry[]).map(
|
||||||
return [
|
(obj: language_matrix_entry) => {
|
||||||
obj["code"],
|
return [obj["code"], obj];
|
||||||
obj,
|
}
|
||||||
]
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
} catch(e) {
|
);
|
||||||
throw new Error(`Can't extract values from data: ${JSON.stringify(data)}`)
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Can't extract values from data: ${JSON.stringify(data)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getDefault() {
|
static async getDefault() {
|
||||||
const settings = await Settings.getDefault();
|
const settings = await Settings.getDefault();
|
||||||
return new LanguageServer(await settings.getLibretranslateBaseUrl() || LIBRETRANSLATE_BASE_URL);
|
return new LanguageServer(
|
||||||
|
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Translator {
|
export class Translator {
|
||||||
constructor(public source : language_t, public defaultTarget : string = "en", private _languageServer : LanguageServer) {
|
constructor(
|
||||||
}
|
public source: language_t,
|
||||||
|
public defaultTarget: string = "en",
|
||||||
|
private _languageServer: LanguageServer
|
||||||
|
) {}
|
||||||
|
|
||||||
get languageServer() {
|
get languageServer() {
|
||||||
return this._languageServer;
|
return this._languageServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async translate(text : string, target : string|undefined = undefined) {
|
async translate(text: string, target: string | undefined = undefined) {
|
||||||
const url = this._languageServer.baseUrl + `/translate`;
|
const url = this._languageServer.baseUrl + `/translate`;
|
||||||
const res = await fetch(url, {
|
console.log(url);
|
||||||
|
const postData = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
q: text,
|
q: text,
|
||||||
@ -84,40 +101,55 @@ export class Translator {
|
|||||||
target: target || this.defaultTarget,
|
target: target || this.defaultTarget,
|
||||||
format: "text",
|
format: "text",
|
||||||
alternatives: 3,
|
alternatives: 3,
|
||||||
api_key: ""
|
api_key: "",
|
||||||
}),
|
}),
|
||||||
headers: { "Content-Type": "application/json" }
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
};
|
||||||
|
|
||||||
|
console.debug("Requesting %s with %o", url, postData);
|
||||||
|
|
||||||
|
const res = await fetch(url, postData);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log(data)
|
if (res.status === 200) {
|
||||||
return data.translatedText
|
console.log(data);
|
||||||
|
return data.translatedText;
|
||||||
|
} else {
|
||||||
|
console.error("Status %d: %s", res.status, JSON.stringify(data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getDefault(defaultTarget: string | undefined = undefined) {
|
static async getDefault(defaultTarget: string | undefined = undefined) {
|
||||||
const settings = await Settings.getDefault();
|
const settings = await Settings.getDefault();
|
||||||
const source = await settings.getHostLanguage();
|
const source = await settings.getHostLanguage() || "en";
|
||||||
return new Translator(source, defaultTarget, await LanguageServer.getDefault())
|
return new Translator(
|
||||||
|
source,
|
||||||
|
defaultTarget,
|
||||||
|
await LanguageServer.getDefault()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CachedTranslator extends Translator {
|
export class CachedTranslator extends Translator {
|
||||||
async translate (text : string, target : string|undefined = undefined) {
|
async translate(text: string, target: string | undefined = undefined) {
|
||||||
const targetKey = target || this.defaultTarget;
|
const targetKey = target || this.defaultTarget;
|
||||||
// console.debug(`Translating from ${this.source} -> ${targetKey}`)
|
// console.debug(`Translating from ${this.source} -> ${targetKey}`)
|
||||||
const key1 = `${this.source}::${targetKey}::${text}`
|
const key1 = `${this.source}::${targetKey}::${text}`;
|
||||||
const tr1 = await cache.get(key1);
|
const tr1 = await cache.get(key1);
|
||||||
if (tr1) return tr1;
|
if (tr1) return tr1;
|
||||||
const tr2 = await super.translate(text, target);
|
const tr2 = await super.translate(text, target);
|
||||||
const key2 = `${this.source}::${targetKey}::${text}`
|
const key2 = `${this.source}::${targetKey}::${text}`;
|
||||||
await cache.set(key2, tr2);
|
await cache.set(key2, tr2);
|
||||||
return tr2;
|
return tr2;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getDefault(defaultTarget: string | undefined = undefined) {
|
static async getDefault(defaultTarget: string | undefined = undefined) {
|
||||||
const settings = await Settings.getDefault();
|
const settings = await Settings.getDefault();
|
||||||
const source = await settings.getHostLanguage();
|
const source = await settings.getHostLanguage() || "en";
|
||||||
return new CachedTranslator(source, defaultTarget, await LanguageServer.getDefault())
|
return new CachedTranslator(
|
||||||
|
source,
|
||||||
|
defaultTarget,
|
||||||
|
await LanguageServer.getDefault()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,18 +1,19 @@
|
|||||||
import { Settings } from '@/app/lib/settings';
|
import { Settings } from '@/app/lib/settings';
|
||||||
import { getDb } from '@/app/lib/db';
|
import { getDb, migrateDb } from '@/app/lib/db';
|
||||||
import { Knex } from 'knex';
|
import { SQLiteDatabase } from 'expo-sqlite';
|
||||||
|
|
||||||
describe('Settings', () => {
|
describe('Settings', () => {
|
||||||
let settings : Settings;
|
let settings: Settings;
|
||||||
let db : Knex;
|
let db: SQLiteDatabase;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
db = await getDb("development");
|
db = await getDb("development");
|
||||||
settings = new Settings(db)
|
await migrateDb("development");
|
||||||
|
settings = new Settings(db);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.migrate.down();
|
await migrateDb("development", "down");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set the host language in the database', async () => {
|
it('should set the host language in the database', async () => {
|
||||||
|
@ -1,101 +1,170 @@
|
|||||||
// components/ui/__tests__/WhisperFile.spec.tsx
|
// app/lib/__tests__/whisper.spec.tsx
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, act } from "@testing-library/react-native";
|
import { getDb } from "@/app/lib/db";
|
||||||
import { WhisperFile } from "@/app/lib/whisper"; // Adjust the import path as necessary
|
import { WhisperFile, WhisperModelTag } from "@/app/lib/whisper"; // Corrected to use WhisperFile and WhisperModelTag instead of WhisperDownloader
|
||||||
|
import { Settings } from "@/app/lib/settings";
|
||||||
|
import { File } from "expo-file-system/next";
|
||||||
|
jest.mock('expo-file-system');
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
|
||||||
|
jest.mock("@/app/lib/db", () => ({
|
||||||
|
getDb: jest.fn().mockResolvedValue({
|
||||||
|
runAsync: jest.fn(),
|
||||||
|
upsert: jest.fn(), // Mock the upsert method used in addToDatabase
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/app/lib/settings", () => ({
|
||||||
|
Settings: {
|
||||||
|
getDefault: jest.fn(() => ({
|
||||||
|
getValue: jest.fn((key) => {
|
||||||
|
switch (key) {
|
||||||
|
case "whisper_model":
|
||||||
|
return "base";
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid setting: '${key}'`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
jest.mock("expo-file-system/next", () => {
|
||||||
|
const _next = jest.requireActual("expo-file-system/next");
|
||||||
|
return {
|
||||||
|
..._next,
|
||||||
|
File: jest.fn().mockImplementation(() => ({
|
||||||
|
..._next.File,
|
||||||
|
text: jest.fn(() => {
|
||||||
|
return new String("text");
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("WhisperFile", () => {
|
describe("WhisperFile", () => {
|
||||||
|
// Corrected to use WhisperFile instead of WhisperDownloader
|
||||||
let whisperFile: WhisperFile;
|
let whisperFile: WhisperFile;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
whisperFile = new WhisperFile("small");
|
whisperFile = new WhisperFile("small");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize correctly", () => {
|
it("should create a download resumable with existing data if available", async () => {
|
||||||
expect(whisperFile).toBeInstanceOf(WhisperFile);
|
const mockExistingData = "mockExistingData";
|
||||||
});
|
|
||||||
|
|
||||||
describe("getModelFileSize", () => {
|
|
||||||
it("should return the correct model file size", async () => {
|
|
||||||
expect(whisperFile.size).toBeUndefined();
|
|
||||||
await whisperFile.updateMetadata();
|
|
||||||
expect(whisperFile.size).toBeGreaterThan(1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getWhisperDownloadStatus", () => {
|
|
||||||
it("should return the correct download status", async () => {
|
|
||||||
const mockStatus = {
|
|
||||||
doesTargetExist: true,
|
|
||||||
isDownloadComplete: false,
|
|
||||||
hasDownloadStarted: true,
|
|
||||||
progress: {
|
|
||||||
current: 100,
|
|
||||||
total: 200,
|
|
||||||
remaining: 100,
|
|
||||||
percentRemaining: 50.0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
jest
|
|
||||||
.spyOn(whisperFile, "getDownloadStatus")
|
|
||||||
.mockResolvedValue(mockStatus);
|
|
||||||
|
|
||||||
const result = await whisperFile.getDownloadStatus();
|
|
||||||
|
|
||||||
expect(result).toEqual(mockStatus);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("initiateWhisperDownload", () => {
|
|
||||||
it("should initiate the download with default options", async () => {
|
|
||||||
const mockModelLabel = "small";
|
|
||||||
jest
|
|
||||||
.spyOn(whisperFile, "createDownloadResumable")
|
|
||||||
.mockResolvedValue(true);
|
|
||||||
|
|
||||||
await whisperFile.initiateWhisperDownload(mockModelLabel);
|
|
||||||
|
|
||||||
expect(whisperFile.createDownloadResumable).toHaveBeenCalledWith(
|
|
||||||
mockModelLabel
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should initiate the download with custom options", async () => {
|
|
||||||
const mockModelLabel = "small";
|
|
||||||
const mockOptions = { force_redownload: true };
|
|
||||||
jest
|
|
||||||
.spyOn(whisperFile, "createDownloadResumable")
|
|
||||||
.mockResolvedValue(true);
|
|
||||||
|
|
||||||
await whisperFile.initiateWhisperDownload(mockModelLabel, mockOptions);
|
|
||||||
|
|
||||||
expect(whisperFile.createDownloadResumable).toHaveBeenCalledWith(
|
|
||||||
mockModelLabel,
|
|
||||||
mockOptions
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the correct download status when target exists and is complete", async () => {
|
|
||||||
jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
|
jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
|
||||||
jest.spyOn(whisperFile, "isDownloadComplete").mockResolvedValue(true);
|
|
||||||
|
|
||||||
expect(await whisperFile.doesTargetExist()).toEqual(true);
|
await whisperFile.createDownloadResumable();
|
||||||
expect(await whisperFile.isDownloadComplete()).toEqual(true);
|
// expect(whisperFile.targetFileName).toEqual("small.bin");
|
||||||
|
expect(whisperFile.targetPath).toContain("small.bin");
|
||||||
|
|
||||||
|
expect(FileSystem.createDownloadResumable).toHaveBeenCalledWith(
|
||||||
|
"https://huggingface.co/openai/whisper-small/resolve/main/pytorch_model.bin",
|
||||||
|
"file:///whisper/small.bin",
|
||||||
|
{},
|
||||||
|
expect.any(Function),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return the correct download status when target does not exist", async () => {
|
// it("should create a download resumable without existing data if not available", async () => {
|
||||||
jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(false);
|
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(false);
|
||||||
|
|
||||||
const result = await whisperFile.getDownloadStatus();
|
// await whisperFile.createDownloadResumable(); // Updated to use createDownloadResumable instead of download
|
||||||
|
|
||||||
expect(result).toEqual({
|
// expect(FileSystem.createDownloadResumable).toHaveBeenCalledWith(
|
||||||
doesTargetExist: false,
|
// "http://mock.model.com/model",
|
||||||
isDownloadComplete: false,
|
// "mockTargetPath",
|
||||||
hasDownloadStarted: false,
|
// {},
|
||||||
progress: undefined,
|
// expect.any(Function),
|
||||||
});
|
// undefined
|
||||||
});
|
// );
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Add more tests as needed for other methods in WhisperFile
|
// it("should update the download status in the database", async () => {
|
||||||
|
// const mockRunAsync = jest.fn();
|
||||||
|
// (getDb as jest.Mock).mockResolvedValue({ runAsync: mockRunAsync });
|
||||||
|
|
||||||
|
// const downloadable = await whisperFile.createDownloadResumable(); // Updated to use createDownloadResumable instead of download
|
||||||
|
// await downloadable.resumeAsync();
|
||||||
|
|
||||||
|
// jest.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
// expect(mockRunAsync).toHaveBeenCalled();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should record the latest target hash after downloading", async () => {
|
||||||
|
// const mockRecordLatestTargetHash = jest.spyOn(
|
||||||
|
// whisperFile,
|
||||||
|
// "recordLatestTargetHash"
|
||||||
|
// );
|
||||||
|
|
||||||
|
// await whisperFile.createDownloadResumable(); // Updated to use createDownloadResumable instead of download
|
||||||
|
|
||||||
|
// expect(mockRecordLatestTargetHash).toHaveBeenCalled();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should call the onData callback if provided", async () => {
|
||||||
|
// const mockOnData = jest.fn();
|
||||||
|
// const options = { onData: mockOnData };
|
||||||
|
|
||||||
|
// await whisperFile.createDownloadResumable(options); // Updated to use createDownloadResumable instead of download
|
||||||
|
|
||||||
|
// expect(mockOnData).toHaveBeenCalledWith(expect.any(Object));
|
||||||
|
// });
|
||||||
|
|
||||||
|
// describe("getDownloadStatus", () => {
|
||||||
|
// it("should return the correct download status when model size is known and download has started", async () => {
|
||||||
|
// whisperFile.size = 1024;
|
||||||
|
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
|
||||||
|
// jest.spyOn(whisperFile, "isDownloadComplete").mockResolvedValue(false);
|
||||||
|
// jest.spyOn(whisperFile, "targetFile").mockReturnValue({
|
||||||
|
// size: 512,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const status = await whisperFile.getDownloadStatus();
|
||||||
|
|
||||||
|
// expect(status).toEqual({
|
||||||
|
// doesTargetExist: true,
|
||||||
|
// isDownloadComplete: false,
|
||||||
|
// hasDownloadStarted: true,
|
||||||
|
// progress: {
|
||||||
|
// current: 512,
|
||||||
|
// total: 1024,
|
||||||
|
// remaining: 512,
|
||||||
|
// percentRemaining: 50.0,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should return the correct download status when model size is known and download is complete", async () => {
|
||||||
|
// whisperFile.size = 1024;
|
||||||
|
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
|
||||||
|
// jest.spyOn(whisperFile, "isDownloadComplete").mockResolvedValue(true);
|
||||||
|
|
||||||
|
// const status = await whisperFile.getDownloadStatus();
|
||||||
|
|
||||||
|
// expect(status).toEqual({
|
||||||
|
// doesTargetExist: true,
|
||||||
|
// isDownloadComplete: true,
|
||||||
|
// hasDownloadStarted: false,
|
||||||
|
// progress: undefined,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should return the correct download status when model size is unknown", async () => {
|
||||||
|
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(false);
|
||||||
|
|
||||||
|
// const status = await whisperFile.getDownloadStatus();
|
||||||
|
|
||||||
|
// expect(status).toEqual({
|
||||||
|
// doesTargetExist: false,
|
||||||
|
// isDownloadComplete: false,
|
||||||
|
// hasDownloadStarted: false,
|
||||||
|
// progress: undefined,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import * as SQLite from "expo-sqlite";
|
import * as SQLite from "expo-sqlite";
|
||||||
import { MIGRATE_UP, MIGRATE_DOWN } from "./migrations";
|
import { MIGRATE_UP, MIGRATE_DOWN } from "./migrations";
|
||||||
|
|
||||||
export async function getDb() {
|
export type db_mode = "development" | "staging" | "production";
|
||||||
return await SQLite.openDatabaseAsync("translation_terrace");
|
|
||||||
|
export async function getDb(mode : db_mode = "production") {
|
||||||
|
return await SQLite.openDatabaseAsync(`translation_terrace_${mode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function migrateDb(direction: "up" | "down" = "up") {
|
export async function migrateDb(mode : db_mode = "production", direction: "up" | "down" = "up") {
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb(mode);
|
||||||
|
|
||||||
const m = direction === "up" ? MIGRATE_UP : MIGRATE_DOWN;
|
const m = direction === "up" ? MIGRATE_UP : MIGRATE_DOWN;
|
||||||
|
|
||||||
|
@ -2,15 +2,16 @@
|
|||||||
export const MIGRATE_UP = {
|
export const MIGRATE_UP = {
|
||||||
1: [
|
1: [
|
||||||
`CREATE TABLE IF NOT EXISTS settings (
|
`CREATE TABLE IF NOT EXISTS settings (
|
||||||
host_language TEXT,
|
key TEXT PRIMARY KEY,
|
||||||
libretranslate_base_url TEXT,
|
value TEXT
|
||||||
ui_direction INTEGER,
|
|
||||||
whisper_model TEXT
|
|
||||||
)`,
|
)`,
|
||||||
],
|
],
|
||||||
2: [
|
2: [
|
||||||
`CREATE TABLE IF NOT EXISTS whisper_models (
|
`CREATE TABLE IF NOT EXISTS whisper_models (
|
||||||
model TEXT PRIMARY KEY,
|
model TEXT PRIMARY KEY,
|
||||||
|
download_status STRING(255),
|
||||||
|
expected_size INTEGER,
|
||||||
|
last_hash STRING(1024),
|
||||||
bytes_done INTEGER,
|
bytes_done INTEGER,
|
||||||
bytes_total INTEGER
|
bytes_total INTEGER
|
||||||
)`,
|
)`,
|
||||||
|
61
app/lib/readstream.ts
Normal file
61
app/lib/readstream.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable unicorn/no-null */
|
||||||
|
import * as fs from 'expo-file-system';
|
||||||
|
import { Readable } from 'readable-stream';
|
||||||
|
|
||||||
|
class ExpoReadStream extends Readable {
|
||||||
|
private readonly fileUri: string;
|
||||||
|
private fileSize: number;
|
||||||
|
private currentPosition: number;
|
||||||
|
private readonly chunkSize: number;
|
||||||
|
|
||||||
|
constructor(fileUri: string, options: fs.ReadingOptions) {
|
||||||
|
super();
|
||||||
|
this.fileUri = fileUri;
|
||||||
|
this.fileSize = 0; // Initialize file size (could be fetched if necessary)
|
||||||
|
this.currentPosition = options.position ?? 0;
|
||||||
|
/**
|
||||||
|
* Default chunk size in bytes. React Native Expo will OOM at 110MB, so we set this to 1/100 of it to balance speed and memory usage and importantly the feedback for user.
|
||||||
|
* If this is too large, the progress bar will be stuck when down stream processing this chunk.
|
||||||
|
*/
|
||||||
|
this.chunkSize = options.length ?? 1024 * 1024;
|
||||||
|
void this._init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _init() {
|
||||||
|
try {
|
||||||
|
const fileInfo = await fs.getInfoAsync(this.fileUri, { size: true });
|
||||||
|
if (fileInfo.exists) {
|
||||||
|
this.fileSize = fileInfo.size ?? 0;
|
||||||
|
} else {
|
||||||
|
this.fileSize = 0;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_read() {
|
||||||
|
const readingOptions = {
|
||||||
|
encoding: fs.EncodingType.Base64,
|
||||||
|
position: this.currentPosition,
|
||||||
|
length: this.chunkSize,
|
||||||
|
} satisfies fs.ReadingOptions;
|
||||||
|
fs.readAsStringAsync(this.fileUri, readingOptions).then(chunk => {
|
||||||
|
if (chunk.length === 0) {
|
||||||
|
// End of the stream
|
||||||
|
this.emit('progress', 1);
|
||||||
|
this.push(null);
|
||||||
|
} else {
|
||||||
|
this.currentPosition = Math.min(this.chunkSize + this.currentPosition, this.fileSize);
|
||||||
|
this.emit('progress', this.fileSize === 0 ? 0.5 : (this.currentPosition / this.fileSize));
|
||||||
|
this.push(Buffer.from(chunk, 'base64'));
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReadStream(fileUri: string, options: { encoding?: fs.EncodingType; end?: number; highWaterMark?: number; start?: number } = {}): ExpoReadStream {
|
||||||
|
return new ExpoReadStream(fileUri, options);
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
|
import { SQLiteDatabase } from "expo-sqlite";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { Knex } from "knex";
|
import { WhisperFile, whisper_model_tag_t } from "./whisper";
|
||||||
|
|
||||||
export class Settings {
|
export class Settings {
|
||||||
|
|
||||||
@ -10,7 +11,7 @@ export class Settings {
|
|||||||
"whisper_model",
|
"whisper_model",
|
||||||
]
|
]
|
||||||
|
|
||||||
constructor(public db: Knex) {
|
constructor(public db: SQLiteDatabase) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,9 +21,9 @@ export class Settings {
|
|||||||
throw new Error(`Invalid setting: '${key}'`)
|
throw new Error(`Invalid setting: '${key}'`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await this.db("settings").select(key).limit(1).first();
|
const row: { value: string } | null = this.db.getFirstSync(`SELECT value FROM settings WHERE key = ?`, key)
|
||||||
if (!(row && row[key])) return undefined;
|
|
||||||
return row[key];
|
return row?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -32,17 +33,11 @@ export class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the key already exists
|
// Check if the key already exists
|
||||||
const [exists] = await this.db("settings").select(1).whereNotNull(key).limit(1);
|
this.db.runSync(`INSERT OR REPLACE INTO
|
||||||
|
settings
|
||||||
if (exists) {
|
(key, value)
|
||||||
// Update the existing column
|
VALUES
|
||||||
await this.db("settings").update({ [key]: value });
|
(?, ?)`, key, value);
|
||||||
} else {
|
|
||||||
// Insert a new value into the specified column
|
|
||||||
const insertData: { [key: string]: any } = {};
|
|
||||||
insertData[key] = value;
|
|
||||||
await this.db("settings").insert(insertData);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setHostLanguage(value: string) {
|
async setHostLanguage(value: string) {
|
||||||
@ -53,7 +48,7 @@ export class Settings {
|
|||||||
return await this.getValue("host_language")
|
return await this.getValue("host_language")
|
||||||
}
|
}
|
||||||
|
|
||||||
async setLibretranslateBaseUrl(value : string) {
|
async setLibretranslateBaseUrl(value: string) {
|
||||||
await this.setValue("libretranslate_base_url", value)
|
await this.setValue("libretranslate_base_url", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,16 +56,15 @@ export class Settings {
|
|||||||
return await this.getValue("libretranslate_base_url")
|
return await this.getValue("libretranslate_base_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
async setWhisperModel(value : string) {
|
async setWhisperModel(value: string) {
|
||||||
await this.setValue("whisper_model", value);
|
await this.setValue("whisper_model", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWhisperModel() {
|
async getWhisperModel() {
|
||||||
return await this.getValue("whisper_model");
|
return await this.getValue("whisper_model") as whisper_model_tag_t;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getDefault() {
|
static async getDefault() {
|
||||||
return new Settings(await getDb())
|
return new Settings(await getDb())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
9
app/lib/util.ts
Normal file
9
app/lib/util.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { TextDecoder } from "util";
|
||||||
|
|
||||||
|
export function arrbufToStr(arrayBuffer : ArrayBuffer) {
|
||||||
|
return new TextDecoder().decode(new Uint8Array(arrayBuffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strToArrBuf(input : string) : Uint8Array<ArrayBufferLike> {
|
||||||
|
return new TextEncoder().encode(input)
|
||||||
|
}
|
@ -1,348 +1,14 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { File, Paths } from "expo-file-system/next";
|
import { File, Paths } from "expo-file-system/next";
|
||||||
import { getDb } from "./db";
|
import FileSystem from "expo-file-system"
|
||||||
import * as Crypto from "expo-crypto";
|
import { pathToFileURLString } from "expo-file-system/src/next/pathUtilities/url";
|
||||||
|
|
||||||
export const WHISPER_MODEL_PATH = Paths.join(
|
export const WHISPER_MODEL_PATH = Paths.join("..", "..", "assets", "whisper");
|
||||||
FileSystem.documentDirectory || "file:///",
|
|
||||||
"whisper"
|
|
||||||
);
|
|
||||||
export const WHISPER_MODEL_DIR = new File(WHISPER_MODEL_PATH);
|
export const WHISPER_MODEL_DIR = new File(WHISPER_MODEL_PATH);
|
||||||
|
|
||||||
// Thanks to https://medium.com/@fabi.mofar/downloading-and-saving-files-in-react-native-expo-5b3499adda84
|
export const WHISPER_MODEL_SMALL_PATH = "file://../../assets/whisper/whisper-small.bin";
|
||||||
|
|
||||||
export async function saveFile(
|
|
||||||
uri: string,
|
|
||||||
filename: string,
|
|
||||||
mimetype: string
|
|
||||||
) {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
const permissions =
|
|
||||||
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
|
||||||
|
|
||||||
if (permissions.granted) {
|
export async function whisperModelExists() {
|
||||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
const file = new File(WHISPER_MODEL_PATH);
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
return file.exists;
|
||||||
});
|
|
||||||
|
|
||||||
await FileSystem.StorageAccessFramework.createFileAsync(
|
|
||||||
permissions.directoryUri,
|
|
||||||
filename,
|
|
||||||
mimetype
|
|
||||||
)
|
|
||||||
.then(async (uri) => {
|
|
||||||
await FileSystem.writeAsStringAsync(uri, base64, {
|
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((e) => console.log(e));
|
|
||||||
} else {
|
|
||||||
shareAsync(uri);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shareAsync(uri);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shareAsync(uri: string) {
|
|
||||||
throw new Error("Function not implemented.");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WHISPER_MODEL_TAGS = ["small", "medium", "large"];
|
|
||||||
export type whisper_model_tag_t = "small" | "medium" | "large";
|
|
||||||
|
|
||||||
export const WHISPER_MODELS = {
|
|
||||||
small: {
|
|
||||||
source:
|
|
||||||
"https://huggingface.co/openai/whisper-small/blob/resolve/pytorch_model.bin",
|
|
||||||
target: "small.bin",
|
|
||||||
label: "Small",
|
|
||||||
size: 967092419,
|
|
||||||
},
|
|
||||||
medium: {
|
|
||||||
source:
|
|
||||||
"https://huggingface.co/openai/whisper-medium/resolve/main/pytorch_model.bin",
|
|
||||||
target: "medium.bin",
|
|
||||||
label: "Medium",
|
|
||||||
size: 3055735323,
|
|
||||||
},
|
|
||||||
large: {
|
|
||||||
source:
|
|
||||||
"https://huggingface.co/openai/whisper-large/resolve/main/pytorch_model.bin",
|
|
||||||
target: "large.bin",
|
|
||||||
label: "Large",
|
|
||||||
size: 6173629930,
|
|
||||||
},
|
|
||||||
} as {
|
|
||||||
[key:whisper_model_tag_t]: {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
label: string;
|
|
||||||
size: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type whisper_tag_t = "small" | "medium" | "large";
|
|
||||||
export type hf_channel_t = "raw" | "resolve";
|
|
||||||
|
|
||||||
export const HF_URL_BASE = "https://huggingface.co/openai/whisper-";
|
|
||||||
export const HF_URL_RAW = "raw";
|
|
||||||
export const HF_URL_RESOLVE = "resolve";
|
|
||||||
export const HF_URL_END = "/main/pytorch_model.bin";
|
|
||||||
|
|
||||||
export function create_hf_url(tag: whisper_tag_t, channel: hf_channel_t) {
|
|
||||||
return `${HF_URL_BASE}${tag}/${channel}${HF_URL_END}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type hf_metadata_t = {
|
|
||||||
version: string;
|
|
||||||
oid: string;
|
|
||||||
size: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type download_status_t = {
|
|
||||||
doesTargetExist: boolean;
|
|
||||||
isDownloadComplete: boolean;
|
|
||||||
hasDownloadStarted: boolean;
|
|
||||||
progress?: {
|
|
||||||
current: number;
|
|
||||||
total: number;
|
|
||||||
remaining: number;
|
|
||||||
percentRemaining: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export class WhisperFile {
|
|
||||||
constructor(
|
|
||||||
public tag: whisper_model_tag_t,
|
|
||||||
private targetFileName?: string,
|
|
||||||
public label?: string,
|
|
||||||
public size?: number
|
|
||||||
) {
|
|
||||||
this.targetFileName = this.targetFileName || `${tag}.bin`;
|
|
||||||
this.label =
|
|
||||||
this.label || `${tag[0].toUpperCase}${tag.substring(1).toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get targetPath() {
|
|
||||||
return Paths.join(WHISPER_MODEL_DIR, this.targetFileName as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
get targetFile() {
|
|
||||||
return new File(this.targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTargetInfo() {
|
|
||||||
return await FileSystem.getInfoAsync(this.targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async doesTargetExist() {
|
|
||||||
return (await this.getTargetInfo()).exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async recordLatestTargetHash() {
|
|
||||||
if (!(await this.doesTargetExist())) {
|
|
||||||
console.debug("%s does not exist", this.targetPath);
|
|
||||||
}
|
|
||||||
const digest1Str = await this.getActualTargetHash();
|
|
||||||
if (!digest1Str) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const db = await getDb();
|
|
||||||
await db("whisper_models")
|
|
||||||
.upsert({
|
|
||||||
model: this.tag,
|
|
||||||
last_hash: digest1Str,
|
|
||||||
})
|
|
||||||
.where({ model: this.tag });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getRecordedTargetHash(): Promise<string> {
|
|
||||||
const db = await getDb();
|
|
||||||
const row = await db("whisper_models").select("last_hash").where({
|
|
||||||
model: this.tag,
|
|
||||||
}).first();
|
|
||||||
return row["last_hash"]
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getActualTargetHash(): Promise<string | undefined> {
|
|
||||||
if (!(await this.doesTargetExist())) {
|
|
||||||
console.debug("%s does not exist", this.targetPath);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const digest1 = await Crypto.digest(
|
|
||||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
|
||||||
this.targetFile.bytes()
|
|
||||||
);
|
|
||||||
const digest1Str = new TextDecoder().decode(new Uint8Array(digest1));
|
|
||||||
return digest1Str;
|
|
||||||
}
|
|
||||||
|
|
||||||
async isTargetCorrupted() {
|
|
||||||
const recordedTargetHash = await this.getRecordedTargetHash();
|
|
||||||
const actualTargetHash = await this.getActualTargetHash();
|
|
||||||
if (!(actualTargetHash || recordedTargetHash)) return false;
|
|
||||||
return actualTargetHash !== recordedTargetHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
async isDownloadComplete() {
|
|
||||||
if (!(await this.doesTargetExist())) {
|
|
||||||
console.debug("%s does not exist", this.targetPath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const data = this.targetFile.bytes();
|
|
||||||
const meta = await this.fetchMetadata();
|
|
||||||
const expectedHash = meta.oid;
|
|
||||||
const digest1: ArrayBuffer = await Crypto.digest(
|
|
||||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
const digest1Str = new TextDecoder().decode(new Uint8Array(digest1));
|
|
||||||
const doesMatch = digest1Str === expectedHash;
|
|
||||||
if (!doesMatch) {
|
|
||||||
console.debug(
|
|
||||||
"sha256 of '%s' does not match expected '%s'",
|
|
||||||
digest1Str,
|
|
||||||
expectedHash
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(ignoreErrors = true) {
|
|
||||||
try {
|
|
||||||
this.targetFile.delete();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
if (!ignoreErrors) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.debug("Created %s", WHISPER_MODEL_DIR);
|
|
||||||
}
|
|
||||||
|
|
||||||
get modelUrl() {
|
|
||||||
return create_hf_url(this.tag, "resolve");
|
|
||||||
}
|
|
||||||
|
|
||||||
get metadataUrl() {
|
|
||||||
return create_hf_url(this.tag, "raw");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchMetadata(): Promise<hf_metadata_t> {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(this.metadataUrl, {
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"User-Agent":
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0",
|
|
||||||
Accept:
|
|
||||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "en-US,en;q=0.5",
|
|
||||||
"Sec-GPC": "1",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
"Sec-Fetch-Dest": "document",
|
|
||||||
"Sec-Fetch-Mode": "navigate",
|
|
||||||
"Sec-Fetch-Site": "cross-site",
|
|
||||||
"If-None-Match": '"8fa71cbce85078986b46fb97caec22039e73351a"',
|
|
||||||
Priority: "u=0, i",
|
|
||||||
},
|
|
||||||
method: "GET",
|
|
||||||
mode: "cors",
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return Object.fromEntries(
|
|
||||||
text.split("\n").map((line) => line.split(" "))
|
|
||||||
) as hf_metadata_t;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch %s: %s", this.metadataUrl, err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMetadata() {
|
|
||||||
const metadata = await this.fetchMetadata();
|
|
||||||
this.size = Number.parseInt(metadata.size);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addToDatabase() {
|
|
||||||
const db = await getDb();
|
|
||||||
await db("whisper_models").upsert({
|
|
||||||
model: this.tag,
|
|
||||||
expected_size: this.size,
|
|
||||||
}).where({
|
|
||||||
model: this.tag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createDownloadResumable(
|
|
||||||
options: {
|
|
||||||
onData?: DownloadCallback | undefined;
|
|
||||||
} = {
|
|
||||||
onData: undefined,
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const existingData = (await this.doesTargetExist())
|
|
||||||
? this.targetFile.text()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (await this.doesTargetExist()) {
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileSystem.createDownloadResumable(
|
|
||||||
this.modelUrl,
|
|
||||||
this.targetPath,
|
|
||||||
{},
|
|
||||||
async (data: FileSystem.DownloadProgressData) => {
|
|
||||||
const db = await getDb();
|
|
||||||
await db.upsert({
|
|
||||||
model: this.tag,
|
|
||||||
download_status: "active",
|
|
||||||
})
|
|
||||||
await this.recordLatestTargetHash();
|
|
||||||
if (options.onData) await options.onData(data);
|
|
||||||
},
|
|
||||||
existingData ? existingData : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDownloadStatus() : Promise<download_status_t> {
|
|
||||||
const doesTargetExist = await this.doesTargetExist();
|
|
||||||
const isDownloadComplete = await this.isDownloadComplete();
|
|
||||||
const hasDownloadStarted = doesTargetExist && !isDownloadComplete;
|
|
||||||
|
|
||||||
if (!this.size) {
|
|
||||||
return {
|
|
||||||
doesTargetExist: false,
|
|
||||||
isDownloadComplete: false,
|
|
||||||
hasDownloadStarted: false,
|
|
||||||
progress: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = hasDownloadStarted
|
|
||||||
? this.size - (this.targetFile.size as number)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const progress = hasDownloadStarted
|
|
||||||
? {
|
|
||||||
current: this.targetFile.size || 0,
|
|
||||||
total: this.size,
|
|
||||||
remaining: this.size - (this.targetFile.size as number),
|
|
||||||
percentRemaining: (remaining / this.size) * 100.0,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
doesTargetExist,
|
|
||||||
isDownloadComplete,
|
|
||||||
hasDownloadStarted,
|
|
||||||
progress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export type DownloadCallback = (arg0: FileSystem.DownloadProgressData) => any;
|
|
||||||
|
@ -1,9 +1,27 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ScrollView, StyleSheet, Text, TouchableHighlight, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableHighlight,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useNavigation, Route } from "@react-navigation/native";
|
import { useNavigation, Route } from "@react-navigation/native";
|
||||||
import { Conversation, Message } from "@/app/lib/conversation";
|
import { Conversation, Message } from "@/app/lib/conversation";
|
||||||
import MessageBubble from "@/components/ui/MessageBubble";
|
import MessageBubble from "@/components/ui/MessageBubble";
|
||||||
import { CachedTranslator, LanguageServer, language_matrix_entry } from "@/app/i18n/api";
|
import {
|
||||||
|
CachedTranslator,
|
||||||
|
LanguageServer,
|
||||||
|
language_matrix_entry,
|
||||||
|
} from "@/app/i18n/api";
|
||||||
|
import {
|
||||||
|
WHISPER_MODEL_SMALL_PATH,
|
||||||
|
whisperModelExists,
|
||||||
|
} from "@/app/lib/whisper";
|
||||||
|
import { initWhisper, WhisperContext } from "whisper.rn";
|
||||||
|
import { useAudioRecorder, AudioModule, RecordingPresets } from "expo-audio";
|
||||||
|
import FileSystem from "expo-file-system";
|
||||||
|
|
||||||
const lasOptions = {
|
const lasOptions = {
|
||||||
sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition
|
sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition
|
||||||
@ -14,11 +32,19 @@ const lasOptions = {
|
|||||||
};
|
};
|
||||||
// LiveAudioStream.init(lasOptions as any);
|
// LiveAudioStream.init(lasOptions as any);
|
||||||
|
|
||||||
const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversation : Conversation}>}) => {
|
const ConversationThread = ({
|
||||||
|
route,
|
||||||
|
}: {
|
||||||
|
route?: Route<"Conversation", { conversation: Conversation }>;
|
||||||
|
}) => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return (<View><Text>Missing Params!</Text></View>)
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>Missing Params!</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. Get the param */
|
/* 2. Get the param */
|
||||||
@ -27,37 +53,94 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
|
|||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [guestSpeak, setGuestSpeak] = useState<string | undefined>();
|
const [guestSpeak, setGuestSpeak] = useState<string | undefined>();
|
||||||
const [guestSpeakLoaded, setGuestSpeakLoaded] = useState<boolean>(false);
|
const [guestSpeakLoaded, setGuestSpeakLoaded] = useState<boolean>(false);
|
||||||
|
const [whisperContext, setWhisperContext] = useState<
|
||||||
|
WhisperContext | undefined
|
||||||
|
>();
|
||||||
const [cachedTranslator, setCachedTranslator] = useState<
|
const [cachedTranslator, setCachedTranslator] = useState<
|
||||||
undefined | CachedTranslator
|
undefined | CachedTranslator
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const [languageLabels, setLanguageLabels] = useState<undefined | {
|
const [languageLabels, setLanguageLabels] = useState<
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
hostNative: {
|
hostNative: {
|
||||||
host: string,
|
host: string;
|
||||||
guest: string,
|
guest: string;
|
||||||
},
|
};
|
||||||
guestNative: {
|
guestNative: {
|
||||||
host: string,
|
host: string;
|
||||||
guest: string,
|
guest: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}>()
|
>();
|
||||||
|
|
||||||
|
// recorder settings
|
||||||
|
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
|
||||||
|
const record = async () => {
|
||||||
|
await audioRecorder.prepareToRecordAsync();
|
||||||
|
audioRecorder.record();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = async () => {
|
||||||
|
// The recording will be available on `audioRecorder.uri`.
|
||||||
|
await audioRecorder.stop();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async function () {
|
||||||
|
|
||||||
const languageServer = await LanguageServer.getDefault();
|
const languageServer = await LanguageServer.getDefault();
|
||||||
const languages = await languageServer.fetchLanguages();
|
try {
|
||||||
const cc = new CachedTranslator(
|
const languages = await languageServer.fetchLanguages(5000);
|
||||||
|
const cachedTranslator = new CachedTranslator(
|
||||||
"en",
|
"en",
|
||||||
conversation.guest.language,
|
conversation.guest.language,
|
||||||
languageServer,
|
languageServer
|
||||||
)
|
);
|
||||||
setCachedTranslator(cc);
|
console.log("Set cached translator from %s", languageServer.baseUrl);
|
||||||
setGuestSpeak(await cc.translate("Speak"));
|
setCachedTranslator(cachedTranslator);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await whisperModelExists())) {
|
||||||
|
throw new Error(`${WHISPER_MODEL_SMALL_PATH} does not exist`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Could not determine if %s exists: %s`,
|
||||||
|
WHISPER_MODEL_SMALL_PATH,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setWhisperContext(
|
||||||
|
await initWhisper({
|
||||||
|
filePath: WHISPER_MODEL_SMALL_PATH,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// recorder settings
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const status = await AudioModule.requestRecordingPermissionsAsync();
|
||||||
|
if (!status.granted) {
|
||||||
|
Alert.alert("Permission to access microphone was denied");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
setGuestSpeak(await cachedTranslator.translate("Speak"));
|
||||||
const hostLang1 = languages[conversation.host.language].name;
|
const hostLang1 = languages[conversation.host.language].name;
|
||||||
const guestLang1 = languages[conversation.host.language].name;
|
const guestLang1 = languages[conversation.host.language].name;
|
||||||
const hostLang2 = await cc.translate(languages[conversation.host.language].name);
|
const hostLang2 = await cachedTranslator.translate(
|
||||||
const guestLang2 = await cc.translate(languages[conversation.host.language].name);
|
languages[conversation.host.language].name
|
||||||
|
);
|
||||||
|
const guestLang2 = await cachedTranslator.translate(
|
||||||
|
languages[conversation.host.language].name
|
||||||
|
);
|
||||||
setLanguageLabels({
|
setLanguageLabels({
|
||||||
hostNative: {
|
hostNative: {
|
||||||
host: hostLang1,
|
host: hostLang1,
|
||||||
@ -66,21 +149,32 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
|
|||||||
guestNative: {
|
guestNative: {
|
||||||
host: hostLang2,
|
host: hostLang2,
|
||||||
guest: guestLang2,
|
guest: guestLang2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"Could not set translator from %s: %s",
|
||||||
|
languageServer.baseUrl,
|
||||||
|
err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const updateMessages = (c: Conversation) => {
|
const updateMessages = (c: Conversation) => {
|
||||||
setMessages([...c]);
|
setMessages([...c]);
|
||||||
};
|
};
|
||||||
|
|
||||||
conversation.onAddMessage = updateMessages;
|
if (!conversation) {
|
||||||
conversation.onTranslationDone = updateMessages;
|
console.warn("Conversation is null or undefined.");
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
conversation.on("add_message", updateMessages);
|
||||||
conversation.onAddMessage = undefined;
|
conversation.on("translation_done", updateMessages);
|
||||||
conversation.onTranslationDone = undefined;
|
|
||||||
};
|
// return () => {
|
||||||
|
// conversation.on("add_message", undefined);
|
||||||
|
// conversation.on("translation_done", undefined);
|
||||||
|
// };
|
||||||
}, [conversation, guestSpeak]);
|
}, [conversation, guestSpeak]);
|
||||||
|
|
||||||
const renderMessages = () =>
|
const renderMessages = () =>
|
||||||
@ -90,11 +184,17 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
|
|||||||
|
|
||||||
return cachedTranslator ? (
|
return cachedTranslator ? (
|
||||||
<View style={{ flex: 1, flexDirection: "column" }}>
|
<View style={{ flex: 1, flexDirection: "column" }}>
|
||||||
{languageLabels && (<View style={styles.languageLabels}>
|
{languageLabels && (
|
||||||
<Text style={styles.nativeHostLabel}>{ languageLabels.hostNative.host } / { languageLabels.hostNative.guest }</Text>
|
<View style={styles.languageLabels}>
|
||||||
<Text style={styles.nativeGuestLabel}>{ languageLabels.guestNative.host } / { languageLabels.guestNative.guest }</Text>
|
<Text style={styles.nativeHostLabel}>
|
||||||
</View>)
|
{languageLabels.hostNative.host} / {languageLabels.hostNative.guest}
|
||||||
}
|
</Text>
|
||||||
|
<Text style={styles.nativeGuestLabel}>
|
||||||
|
{languageLabels.guestNative.host} /{" "}
|
||||||
|
{languageLabels.guestNative.guest}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{
|
style={{
|
||||||
borderColor: "black",
|
borderColor: "black",
|
||||||
@ -134,15 +234,9 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
languageLabels: {
|
languageLabels: {},
|
||||||
|
nativeHostLabel: {},
|
||||||
},
|
nativeGuestLabel: {},
|
||||||
nativeHostLabel: {
|
});
|
||||||
|
|
||||||
},
|
|
||||||
nativeGuestLabel: {
|
|
||||||
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export default ConversationThread;
|
export default ConversationThread;
|
||||||
|
@ -8,6 +8,7 @@ import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
|||||||
import { Conversation, Speaker } from "@/app/lib/conversation";
|
import { Conversation, Speaker } from "@/app/lib/conversation";
|
||||||
import { NavigationProp, ParamListBase } from "@react-navigation/native";
|
import { NavigationProp, ParamListBase } from "@react-navigation/native";
|
||||||
import { Link, useNavigation } from "expo-router";
|
import { Link, useNavigation } from "expo-router";
|
||||||
|
import { migrateDb } from "@/app/lib/db";
|
||||||
|
|
||||||
|
|
||||||
export function LanguageSelection(props: {
|
export function LanguageSelection(props: {
|
||||||
@ -17,7 +18,7 @@ export function LanguageSelection(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [languages, setLanguages] = useState<language_matrix | undefined>();
|
const [languages, setLanguages] = useState<language_matrix | undefined>();
|
||||||
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
|
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
|
||||||
const [translator, setTranslator] = useState<Translator|undefined>();
|
const [translator, setTranslator] = useState<Translator | undefined>();
|
||||||
|
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
|
||||||
@ -30,11 +31,12 @@ export function LanguageSelection(props: {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
await migrateDb();
|
||||||
try {
|
try {
|
||||||
// Replace with your actual async data fetching logic
|
// Replace with your actual async data fetching logic
|
||||||
setTranslator(await CachedTranslator.getDefault());
|
setTranslator(await CachedTranslator.getDefault());
|
||||||
const languageServer = await LanguageServer.getDefault();
|
const languageServer = await LanguageServer.getDefault();
|
||||||
const languages = await languageServer.fetchLanguages(5000);
|
const languages = await languageServer.fetchLanguages(10000);
|
||||||
setLanguages(languages);
|
setLanguages(languages);
|
||||||
setLanguagesLoaded(true);
|
setLanguagesLoaded(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -49,8 +51,8 @@ export function LanguageSelection(props: {
|
|||||||
<Text>Settings</Text>
|
<Text>Settings</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<ScrollView >
|
<ScrollView >
|
||||||
<SafeAreaProvider >
|
<SafeAreaProvider>
|
||||||
<SafeAreaView>
|
<SafeAreaView style={styles.table}>
|
||||||
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
|
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
|
||||||
([lang, lang_entry]) => {
|
([lang, lang_entry]) => {
|
||||||
return (
|
return (
|
||||||
@ -66,11 +68,15 @@ export function LanguageSelection(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEBUG_BORDER = {
|
||||||
|
borderWidth: 3,
|
||||||
|
borderStyle: "dotted",
|
||||||
|
borderColor: "blue",
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
column: {
|
table: {
|
||||||
flex: 1,
|
flexDirection: "row",
|
||||||
flexDirection: 'row',
|
flexWrap: "wrap",
|
||||||
flexWrap: 'wrap',
|
|
||||||
padding: 8,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
@ -1,22 +1,11 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
|
import { View, Text, TextInput, StyleSheet } from "react-native";
|
||||||
import {
|
|
||||||
WhisperFile,
|
|
||||||
download_status_t,
|
|
||||||
whisper_tag_t,
|
|
||||||
} from "@/app/lib/whisper";
|
|
||||||
import { Settings } from "@/app/lib/settings";
|
import { Settings } from "@/app/lib/settings";
|
||||||
import { Picker } from "@react-native-picker/picker";
|
import { Picker } from "@react-native-picker/picker";
|
||||||
import {
|
import {
|
||||||
LanguageServer,
|
LanguageServer,
|
||||||
language_matrix,
|
language_matrix,
|
||||||
language_matrix_entry,
|
|
||||||
} from "@/app/i18n/api";
|
} from "@/app/i18n/api";
|
||||||
const WHISPER_MODELS = {
|
|
||||||
small: new WhisperFile("small"),
|
|
||||||
medium: new WhisperFile("medium"),
|
|
||||||
large: new WhisperFile("large"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIBRETRANSLATE_BASE_URL = "https://translate.argosopentech.com/translate";
|
const LIBRETRANSLATE_BASE_URL = "https://translate.argosopentech.com/translate";
|
||||||
|
|
||||||
@ -32,34 +21,17 @@ const SettingsComponent = () => {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [whisperModel, setWhisperModel] =
|
|
||||||
useState<keyof typeof WHISPER_MODELS>("small");
|
|
||||||
const [downloader, setDownloader] = useState<any>(null);
|
|
||||||
const [whisperFile, setWhisperFile] = useState<WhisperFile | undefined>();
|
|
||||||
const [downloadStatus, setDownloadStatus] = useState<
|
|
||||||
undefined | download_status_t
|
|
||||||
>();
|
|
||||||
const [statusTimeout, setStatusTimeout] = useState<
|
|
||||||
NodeJS.Timeout | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
(async function () {
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getLanguageOptions = async () => {
|
|
||||||
const languageServer = await LanguageServer.getDefault();
|
|
||||||
setLanguageOptions(await languageServer.fetchLanguages());
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSettings = async () => {
|
|
||||||
const settings = await Settings.getDefault();
|
const settings = await Settings.getDefault();
|
||||||
setHostLanguage((await settings.getHostLanguage()) || "en");
|
setHostLanguage((await settings.getHostLanguage()) || "en");
|
||||||
setLibretranslateBaseUrl(
|
setLibretranslateBaseUrl(
|
||||||
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL
|
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL
|
||||||
);
|
);
|
||||||
setWhisperModel(await settings.getWhisperModel());
|
})();
|
||||||
};
|
});
|
||||||
|
|
||||||
|
|
||||||
const handleHostLanguageChange = async (lang: string) => {
|
const handleHostLanguageChange = async (lang: string) => {
|
||||||
const settings = await Settings.getDefault();
|
const settings = await Settings.getDefault();
|
||||||
@ -77,56 +49,17 @@ const SettingsComponent = () => {
|
|||||||
const checkLangServerConnection = async (baseUrl: string) => {
|
const checkLangServerConnection = async (baseUrl: string) => {
|
||||||
try {
|
try {
|
||||||
// Replace with actual connection check logic
|
// Replace with actual connection check logic
|
||||||
setLangServerConn({ success: true });
|
const testResult = await fetch(baseUrl, {
|
||||||
|
method: "HEAD",
|
||||||
|
});
|
||||||
|
if (testResult.status !== 200) {
|
||||||
|
setLangServerConn({ success: true, error: testResult.statusText });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLangServerConn({ success: false, error: `${error}` });
|
setLangServerConn({ success: false, error: `${error}` });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const intervalUpdateDownloadStatus = async () => {
|
|
||||||
if (!whisperFile) return;
|
|
||||||
const status = await whisperFile.getDownloadStatus();
|
|
||||||
setDownloadStatus(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWhisperModelChange = async (model: whisper_tag_t) => {
|
|
||||||
const settings = await Settings.getDefault();
|
|
||||||
await settings.setWhisperModel(model);
|
|
||||||
setWhisperModel(model);
|
|
||||||
setWhisperFile(new WhisperFile(model));
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDownload = async () => {
|
|
||||||
if (!whisperModel) {
|
|
||||||
throw new Error("Could not start download because whisperModel not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Starging download of %s", whisperModel)
|
|
||||||
|
|
||||||
const whisperFile = new WhisperFile(whisperModel);
|
|
||||||
|
|
||||||
const resumable = await whisperFile.createDownloadResumable();
|
|
||||||
setDownloader(resumable);
|
|
||||||
try {
|
|
||||||
await resumable.downloadAsync();
|
|
||||||
const statusTimeout = setInterval(intervalUpdateDownloadStatus, 200);
|
|
||||||
setStatusTimeout(statusTimeout);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to download whisper model:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doStopDownload = async () => {
|
|
||||||
downloader.cancelAsync();
|
|
||||||
setDownloader(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDelete = async () => {
|
|
||||||
const whisperFile = WHISPER_MODELS[whisperModel];
|
|
||||||
whisperFile.delete();
|
|
||||||
setStatusTimeout(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return hostLanguage && libretranslateBaseUrl ? (
|
return hostLanguage && libretranslateBaseUrl ? (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.label}>Host Language:</Text>
|
<Text style={styles.label}>Host Language:</Text>
|
||||||
@ -159,47 +92,6 @@ const SettingsComponent = () => {
|
|||||||
Error connecting to {libretranslateBaseUrl}: {langServerConn.error}
|
Error connecting to {libretranslateBaseUrl}: {langServerConn.error}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
<Picker
|
|
||||||
selectedValue={whisperModel}
|
|
||||||
style={{ height: 50, width: "100%" }}
|
|
||||||
onValueChange={handleWhisperModelChange}
|
|
||||||
accessibilityHint="whisper models"
|
|
||||||
>
|
|
||||||
{Object.entries(WHISPER_MODELS).map(([key, whisperFile]) => (
|
|
||||||
<Picker.Item
|
|
||||||
key={whisperFile.tag}
|
|
||||||
label={whisperFile.label}
|
|
||||||
value={key}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Picker>
|
|
||||||
<View>
|
|
||||||
{whisperModel &&
|
|
||||||
(downloadStatus?.isDownloadComplete ? (
|
|
||||||
downloadStatus?.doesTargetExist ? (
|
|
||||||
<Pressable onPress={doDelete}>
|
|
||||||
<Text>DELETE {whisperModel.toUpperCase()}</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<Pressable onPress={doStopDownload}>
|
|
||||||
<Text>PAUSE</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Pressable onPress={doDownload}>
|
|
||||||
<Text>DOWNLOAD {whisperModel.toUpperCase()}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
{downloadStatus?.progress && (
|
|
||||||
<View>
|
|
||||||
<Text>
|
|
||||||
{downloadStatus.progress.current} of{" "}
|
|
||||||
{downloadStatus.progress.total} (
|
|
||||||
{downloadStatus.progress.percentRemaining} %){" "}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View>
|
<View>
|
||||||
@ -214,11 +106,12 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
},
|
},
|
||||||
downloadButton: {
|
downloadButton: {
|
||||||
backgroundColor: "darkblue",
|
backgroundColor: "#236b9f",
|
||||||
padding: 20,
|
padding: 20,
|
||||||
margin: 10,
|
margin: 10,
|
||||||
flex: 3,
|
flex: 1,
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
deleteButton: {
|
deleteButton: {
|
||||||
backgroundColor: "darkred",
|
backgroundColor: "darkred",
|
||||||
@ -236,11 +129,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
flex: 1,
|
// flex: 1,
|
||||||
fontSize: 16,
|
// fontSize: 16,
|
||||||
alignSelf: "center",
|
// alignSelf: "center",
|
||||||
textAlign: "center",
|
// textAlign: "center",
|
||||||
textAlignVertical: "top",
|
// textAlignVertical: "top",
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
jest.mock("@/app/i18n/api", () => require("../../__mocks__/api.ts"));
|
jest.mock("@/app/i18n/api", () => require("../../__mocks__/api.ts"));
|
||||||
import { renderRouter} from 'expo-router/testing-library';
|
import { renderRouter } from "expo-router/testing-library";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
@ -13,14 +13,21 @@ import {
|
|||||||
createNavigationContainerRef,
|
createNavigationContainerRef,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import TTNavStack from "../TTNavStack";
|
import TTNavStack from "../TTNavStack";
|
||||||
|
import { migrateDb } from "@/app/lib/db";
|
||||||
|
|
||||||
describe("Navigation", () => {
|
describe("Navigation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
await migrateDb("development", "up");
|
||||||
// Reset the navigation state before each test
|
// Reset the navigation state before each test
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await migrateDb("development", "down");
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("Navigates to ConversationThread on language selection", async () => {
|
it("Navigates to ConversationThread on language selection", async () => {
|
||||||
const MockComponent = jest.fn(() => <TTNavStack />);
|
const MockComponent = jest.fn(() => <TTNavStack />);
|
||||||
renderRouter(
|
renderRouter(
|
||||||
@ -28,7 +35,7 @@ describe("Navigation", () => {
|
|||||||
index: MockComponent,
|
index: MockComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialUrl: '/',
|
initialUrl: "/",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const languageSelectionText = await waitFor(() =>
|
const languageSelectionText = await waitFor(() =>
|
||||||
@ -47,14 +54,16 @@ describe("Navigation", () => {
|
|||||||
index: MockComponent,
|
index: MockComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialUrl: '/',
|
initialUrl: "/",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const settingsButton = await waitFor(() =>
|
const settingsButton = await waitFor(() =>
|
||||||
screen.getByText(/.*Settings.*/i)
|
screen.getByText(/.*Settings.*/i)
|
||||||
);
|
);
|
||||||
fireEvent.press(settingsButton);
|
fireEvent.press(settingsButton);
|
||||||
expect(await waitFor(() => screen.getByText(/Settings/i))).toBeOnTheScreen();
|
expect(
|
||||||
|
await waitFor(() => screen.getByText(/Settings/i))
|
||||||
|
).toBeOnTheScreen();
|
||||||
// expect(waitFor(() => screen.getByText(/Settings/i))).toBeTruthy()
|
// expect(waitFor(() => screen.getByText(/Settings/i))).toBeTruthy()
|
||||||
expect(screen.getByText("Settings")).toBeOnTheScreen();
|
expect(screen.getByText("Settings")).toBeOnTheScreen();
|
||||||
});
|
});
|
||||||
|
@ -106,7 +106,9 @@ const ISpeakButton = (props: ISpeakButtonProps) => {
|
|||||||
<View style={styles.flag}>
|
<View style={styles.flag}>
|
||||||
{countries &&
|
{countries &&
|
||||||
countries.map((c) => {
|
countries.map((c) => {
|
||||||
return <CountryFlag isoCode={c} size={25} key={c} />;
|
return (
|
||||||
|
<CountryFlag isoCode={c} size={25} key={c} />
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
@ -121,14 +123,13 @@ const ISpeakButton = (props: ISpeakButtonProps) => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
button: {
|
button: {
|
||||||
width: "20%",
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
borderColor: "white",
|
borderColor: "white",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
height: 110,
|
height: 110,
|
||||||
alignSelf: "flex-start",
|
width: 170,
|
||||||
margin: 8,
|
margin: 10,
|
||||||
},
|
},
|
||||||
flag: {},
|
flag: {},
|
||||||
iSpeak: {
|
iSpeak: {
|
||||||
|
@ -3,9 +3,10 @@ import { render, screen, fireEvent, act } from "@testing-library/react-native";
|
|||||||
import SettingsComponent from "@/components/Settings";
|
import SettingsComponent from "@/components/Settings";
|
||||||
import { language_matrix } from "@/app/i18n/api";
|
import { language_matrix } from "@/app/i18n/api";
|
||||||
import { Settings } from "@/app/lib/settings";
|
import { Settings } from "@/app/lib/settings";
|
||||||
import { getDb } from "@/app/lib/db";
|
import { getDb, migrateDb } from "@/app/lib/db";
|
||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
import { WhisperFile } from "@/app/lib/whisper";
|
import { WhisperFile } from "@/app/lib/whisper";
|
||||||
|
import { SQLiteDatabase } from "expo-sqlite";
|
||||||
|
|
||||||
const RENDER_TIME = 1000;
|
const RENDER_TIME = 1000;
|
||||||
|
|
||||||
@ -77,11 +78,12 @@ jest.mock("@/app/i18n/api", () => {
|
|||||||
|
|
||||||
|
|
||||||
describe("SettingsComponent", () => {
|
describe("SettingsComponent", () => {
|
||||||
let db: Knex;
|
let db: SQLiteDatabase;
|
||||||
let settings: Settings;
|
let settings: Settings;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
db = await getDb("development");
|
db = await getDb("development");
|
||||||
|
await migrateDb("development");
|
||||||
settings = new Settings(db);
|
settings = new Settings(db);
|
||||||
jest.spyOn(Settings, 'getDefault').mockResolvedValue(settings);
|
jest.spyOn(Settings, 'getDefault').mockResolvedValue(settings);
|
||||||
await settings.setHostLanguage("en");
|
await settings.setHostLanguage("en");
|
||||||
@ -90,8 +92,7 @@ describe("SettingsComponent", () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
await db.migrate.down();
|
await migrateDb("development", "down");
|
||||||
await db.destroy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
52
jestSetup.ts
52
jestSetup.ts
@ -9,43 +9,37 @@ jest.mock("expo-sqlite", () => {
|
|||||||
|
|
||||||
const { MIGRATE_UP } = jest.requireActual("./app/lib/migrations");
|
const { MIGRATE_UP } = jest.requireActual("./app/lib/migrations");
|
||||||
|
|
||||||
const openDatabaseAsync = async (name: string) => {
|
const genericRun = (sql: string, ... params : string []) => {
|
||||||
return {
|
|
||||||
closeAsync: jest.fn(() => db.close()),
|
|
||||||
executeSql: jest.fn((sql: string) => db.exec(sql)),
|
|
||||||
runAsync: jest.fn(async (sql: string, params = []) => {
|
|
||||||
for (let m of Object.values(MIGRATE_UP)) {
|
|
||||||
for (let stmt of m) {
|
|
||||||
const s = db.prepare(stmt);
|
|
||||||
s.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const stmt = db.prepare(sql);
|
|
||||||
// console.log("Running %s with %s", sql, params);
|
// console.log("Running %s with %s", sql, params);
|
||||||
try {
|
try {
|
||||||
stmt.run(params);
|
const stmt = db.prepare(sql);
|
||||||
|
stmt.run(...params);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`running ${sql} with params ${JSON.stringify(params)}: ${e}`
|
`running ${sql} with params ${JSON.stringify(params)}: ${e}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
getFirstAsync: jest.fn(async (sql: string, params = []) => {
|
|
||||||
for (let m of Object.values(MIGRATE_UP)) {
|
|
||||||
for (let stmt of m) {
|
|
||||||
const s = db.prepare(stmt);
|
|
||||||
s.run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genericGetFirst = (sql: string, params = []) => {
|
||||||
const stmt = db.prepare(sql);
|
const stmt = db.prepare(sql);
|
||||||
// const result = stmt.run(...params);
|
// const result = stmt.run(...params);
|
||||||
return stmt.get(params);
|
return stmt.get(params);
|
||||||
}),
|
};
|
||||||
|
|
||||||
|
const openDatabaseAsync = async (name: string) => {
|
||||||
|
return {
|
||||||
|
closeAsync: jest.fn(() => db.close()),
|
||||||
|
executeSql: jest.fn((sql: string) => db.exec(sql)),
|
||||||
|
runAsync: jest.fn(genericRun),
|
||||||
|
runSync: jest.fn(genericRun),
|
||||||
|
getFirstAsync: jest.fn(genericGetFirst),
|
||||||
|
getFirstSync: jest.fn(genericGetFirst),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
migrateDb: async (direction: "up" | "down" = "up") => {
|
migrateDb: async (direction: "up" | "down" = "up") => {
|
||||||
const db = await openDatabaseAsync("translation_terrace");
|
const db = await openDatabaseAsync("translation_terrace_development");
|
||||||
for (let m of Object.values(MIGRATE_UP)) {
|
for (let m of Object.values(MIGRATE_UP)) {
|
||||||
for (let stmt of m) {
|
for (let stmt of m) {
|
||||||
await db.executeSql(stmt);
|
await db.executeSql(stmt);
|
||||||
@ -120,3 +114,17 @@ jest.mock('@/app/lib/settings', () => {
|
|||||||
default: MockSettings
|
default: MockSettings
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('expo-file-system', () => ({
|
||||||
|
// ... other methods ...
|
||||||
|
createDownloadResumable: jest.fn(() => ({
|
||||||
|
downloadAsync: jest.fn(() => Promise.resolve({ uri: 'mocked-uri' })),
|
||||||
|
pauseAsync: jest.fn(() => Promise.resolve()),
|
||||||
|
resumeAsync: jest.fn(() => Promise.resolve()),
|
||||||
|
cancelAsync: jest.fn(() => Promise.resolve()),
|
||||||
|
})),
|
||||||
|
getInfoAsync: jest.fn(() => ({
|
||||||
|
exists: () => false,
|
||||||
|
}))
|
||||||
|
// ... other methods ...
|
||||||
|
}));
|
||||||
|
129
package-lock.json
generated
129
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/native-stack": "^7.2.0",
|
"@react-navigation/native-stack": "^7.2.0",
|
||||||
"expo": "~52.0.28",
|
"expo": "~52.0.28",
|
||||||
|
"expo-audio": "~0.3.4",
|
||||||
"expo-background-fetch": "~13.0.5",
|
"expo-background-fetch": "~13.0.5",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-constants": "~17.0.6",
|
"expo-constants": "~17.0.6",
|
||||||
@ -49,6 +50,7 @@
|
|||||||
"react-native-sqlite-storage": "^6.0.1",
|
"react-native-sqlite-storage": "^6.0.1",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "13.12.5",
|
"react-native-webview": "13.12.5",
|
||||||
|
"readable-stream": "^4.7.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"whisper.rn": "^0.3.9"
|
"whisper.rn": "^0.3.9"
|
||||||
@ -65,6 +67,7 @@
|
|||||||
"@types/react-native-sqlite-storage": "^6.0.5",
|
"@types/react-native-sqlite-storage": "^6.0.5",
|
||||||
"@types/react-navigation": "^3.0.8",
|
"@types/react-navigation": "^3.0.8",
|
||||||
"@types/react-test-renderer": "^18.3.1",
|
"@types/react-test-renderer": "^18.3.1",
|
||||||
|
"@types/readable-stream": "^4.0.18",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
"expo": "~52.0.28",
|
"expo": "~52.0.28",
|
||||||
@ -5046,6 +5049,24 @@
|
|||||||
"@types/react": "^18"
|
"@types/react": "^18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/readable-stream": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"safe-buffer": "~5.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/readable-stream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/stack-utils": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@ -5589,6 +5610,21 @@
|
|||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/are-we-there-yet/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@ -6030,6 +6066,20 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bl/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bn.js": {
|
"node_modules/bn.js": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
|
||||||
@ -8162,6 +8212,17 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-audio": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-gzpDH3vZI1FDL1Q8pXryACtNIW+idZ/zIZ8WqdTRzJuzxucazrG2gLXUS2ngcXQBn09Jyz4RUnU10Tu2N7/Hgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-background-fetch": {
|
"node_modules/expo-background-fetch": {
|
||||||
"version": "13.0.5",
|
"version": "13.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-13.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-13.0.5.tgz",
|
||||||
@ -15204,17 +15265,43 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"abort-controller": "^3.0.0",
|
||||||
"string_decoder": "^1.1.1",
|
"buffer": "^6.0.3",
|
||||||
"util-deprecate": "^1.0.1"
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readline": {
|
"node_modules/readline": {
|
||||||
@ -16439,6 +16526,20 @@
|
|||||||
"readable-stream": "^3.5.0"
|
"readable-stream": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-browserify/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stream-buffers": {
|
"node_modules/stream-buffers": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
|
||||||
@ -16837,6 +16938,20 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-stream/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar/node_modules/fs-minipass": {
|
"node_modules/tar/node_modules/fs-minipass": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||||
|
@ -9,7 +9,8 @@
|
|||||||
"updateSnapshots": "jest -u --coverage=false",
|
"updateSnapshots": "jest -u --coverage=false",
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo prebuild --npm -p android",
|
"prebuild:android": "expo prebuild --npm -p android",
|
||||||
|
"android": "expo run:android",
|
||||||
"ios": "expo prebuild --npm -p android --offline",
|
"ios": "expo prebuild --npm -p android --offline",
|
||||||
"web": "expo start --offline --web",
|
"web": "expo start --offline --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
@ -24,6 +25,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/native-stack": "^7.2.0",
|
"@react-navigation/native-stack": "^7.2.0",
|
||||||
"expo": "~52.0.28",
|
"expo": "~52.0.28",
|
||||||
|
"expo-audio": "~0.3.4",
|
||||||
"expo-background-fetch": "~13.0.5",
|
"expo-background-fetch": "~13.0.5",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-constants": "~17.0.6",
|
"expo-constants": "~17.0.6",
|
||||||
@ -56,6 +58,7 @@
|
|||||||
"react-native-sqlite-storage": "^6.0.1",
|
"react-native-sqlite-storage": "^6.0.1",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "13.12.5",
|
"react-native-webview": "13.12.5",
|
||||||
|
"readable-stream": "^4.7.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"whisper.rn": "^0.3.9"
|
"whisper.rn": "^0.3.9"
|
||||||
@ -85,6 +88,7 @@
|
|||||||
"@types/react-native-sqlite-storage": "^6.0.5",
|
"@types/react-native-sqlite-storage": "^6.0.5",
|
||||||
"@types/react-navigation": "^3.0.8",
|
"@types/react-navigation": "^3.0.8",
|
||||||
"@types/react-test-renderer": "^18.3.1",
|
"@types/react-test-renderer": "^18.3.1",
|
||||||
|
"@types/readable-stream": "^4.0.18",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"babel-plugin-module-resolver": "^5.0.2",
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
"expo": "~52.0.28",
|
"expo": "~52.0.28",
|
||||||
|
Reference in New Issue
Block a user