update for whisper
This commit is contained in:
@@ -317,6 +317,7 @@
|
||||
"name": "mobile-voice",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fugood/react-native-audio-pcm-stream": "1.1.4",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/elements": "^2.9.10",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
@@ -345,14 +346,13 @@
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-audio-api": "^0.11.7",
|
||||
"react-native-executorch": "^0.8.0",
|
||||
"react-native-executorch-expo-resource-fetcher": "^0.8.0",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"whisper.rn": "0.5.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
@@ -1378,6 +1378,8 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@fugood/react-native-audio-pcm-stream": ["@fugood/react-native-audio-pcm-stream@1.1.4", "", {}, "sha512-M6H6ay4ea0vpioII9T/C9qXFPeGpxGN24nl0REP2/wtsorZXg3zzHjZbf3UUUwjf6lEEHMlGCJfXUsxwC/vV8w=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
@@ -1396,8 +1398,6 @@
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.6", "", {}, "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA=="],
|
||||
|
||||
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
|
||||
|
||||
"@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="],
|
||||
@@ -3918,10 +3918,6 @@
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"jsonrepair": ["jsonrepair@3.13.3", "", { "bin": { "jsonrepair": "bin/cli.js" } }, "sha512-BTznj0owIt2CBAH/LTo7+1I5pMvl1e1033LRl/HUowlZmJOIhzC0zbX5bxMngLkfT4WnzPP26QnW5wMr2g9tsQ=="],
|
||||
|
||||
"jsonschema": ["jsonschema@1.5.0", "", {}, "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
@@ -4536,7 +4532,7 @@
|
||||
|
||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
|
||||
|
||||
@@ -4650,10 +4646,6 @@
|
||||
|
||||
"react-native-audio-api": ["react-native-audio-api@0.11.7", "", { "dependencies": { "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "*" }, "bin": { "setup-rn-audio-api-web": "scripts/setup-rn-audio-api-web.js" } }, "sha512-2oIoP77Tn2nlouRVfEC3bAsuSyKU6xhGNkSnVXTLLQQZslEDoYX2cN9pVRZoWOqhFrLT8q4IZI9HaFgYL13L1A=="],
|
||||
|
||||
"react-native-executorch": ["react-native-executorch@0.8.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.0", "jsonrepair": "^3.12.0", "jsonschema": "^1.5.0", "pngjs": "^7.0.0", "zod": "^4.3.6" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-9zRiJiCSTOYbES4htuk+yqkhgec/i4L1E63ZYgJ1AHkDbvHyoYLH3KkKjjzxDw7NYJCCOx+6vj9l9JrodoCbzg=="],
|
||||
|
||||
"react-native-executorch-expo-resource-fetcher": ["react-native-executorch-expo-resource-fetcher@0.8.0", "", { "peerDependencies": { "expo": ">=54.0.0", "expo-asset": ">=12.0.0", "expo-file-system": ">=19.0.0", "react-native": "*", "react-native-executorch": "*" } }, "sha512-vdAne2FBL0nCQ2c2yHTSt8Uttm0Klmo/K7tirSVlKxgVtli4cmsfl+UpR5giaNtlRZ3ImMAMXNW34j0fItmRfQ=="],
|
||||
|
||||
"react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="],
|
||||
|
||||
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="],
|
||||
@@ -5446,6 +5438,8 @@
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"whisper.rn": ["whisper.rn@0.5.5", "", { "dependencies": { "safe-buffer": "^5.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-awFE+ImMtRdGhA+hjm3GEwnSvyEVP1sdhMb+MyCa5bVdoOCpaxrwVwXDo9U46Qwkhwml3PCFaauTsGmRkTyhdw=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="],
|
||||
|
||||
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
|
||||
@@ -5846,6 +5840,8 @@
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@jimp/js-png/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
@@ -6556,8 +6552,6 @@
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"qrcode/pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
@@ -6582,8 +6576,6 @@
|
||||
|
||||
"react-native/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"react-native-executorch/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
|
||||
|
||||
"react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "mobile-voice",
|
||||
"name": "Control",
|
||||
"slug": "mobile-voice",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
@@ -10,6 +10,9 @@
|
||||
"ios": {
|
||||
"icon": "./assets/images/icon.png",
|
||||
"bundleIdentifier": "com.anomalyco.mobilevoice",
|
||||
"entitlements": {
|
||||
"com.apple.developer.kernel.extended-virtual-addressing": true
|
||||
},
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "This app needs microphone access for live speech-to-text dictation.",
|
||||
"NSAppTransportSecurity": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fugood/react-native-audio-pcm-stream": "1.1.4",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/elements": "^2.9.10",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
@@ -41,14 +42,13 @@
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-audio-api": "^0.11.7",
|
||||
"react-native-executorch": "^0.8.0",
|
||||
"react-native-executorch-expo-resource-fetcher": "^0.8.0",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.7.2"
|
||||
"react-native-worklets": "0.7.2",
|
||||
"whisper.rn": "0.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Slot } from 'expo-router';
|
||||
import { LogBox } from 'react-native';
|
||||
import { initExecutorch } from 'react-native-executorch';
|
||||
import { ExpoResourceFetcher } from 'react-native-executorch-expo-resource-fetcher';
|
||||
import React from "react"
|
||||
import { Slot } from "expo-router"
|
||||
import { LogBox } from "react-native"
|
||||
import {
|
||||
configureNotificationBehavior,
|
||||
registerBackgroundNotificationTask,
|
||||
} from '@/notifications/monitoring-notifications';
|
||||
|
||||
// Initialize the ExecuTorch resource fetcher before any model hooks run
|
||||
initExecutorch({ resourceFetcher: ExpoResourceFetcher });
|
||||
} from "@/notifications/monitoring-notifications"
|
||||
|
||||
// Suppress known non-actionable warnings from third-party libs.
|
||||
LogBox.ignoreLogs([
|
||||
'RecordingNotificationManager is not implemented on iOS',
|
||||
'[React Native ExecuTorch] No content-length header',
|
||||
]);
|
||||
"RecordingNotificationManager is not implemented on iOS",
|
||||
"`transcribeRealtime` is deprecated, use `RealtimeTranscriber` instead",
|
||||
])
|
||||
|
||||
configureNotificationBehavior();
|
||||
registerBackgroundNotificationTask().catch(() => {});
|
||||
configureNotificationBehavior()
|
||||
registerBackgroundNotificationTask().catch(() => {})
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Slot />;
|
||||
return <Slot />
|
||||
}
|
||||
|
||||
+1187
-273
@@ -7,6 +7,7 @@ import {
|
||||
ScrollView,
|
||||
Modal,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
LayoutChangeEvent,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
@@ -25,11 +26,13 @@ import Animated, {
|
||||
} from "react-native-reanimated"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import { StatusBar } from "expo-status-bar"
|
||||
import { SymbolView } from "expo-symbols"
|
||||
import * as Haptics from "expo-haptics"
|
||||
import { useAudioPlayer } from "expo-audio"
|
||||
import { useSpeechToText, WHISPER_BASE_EN } from "react-native-executorch"
|
||||
import { ExpoResourceFetcher } from "react-native-executorch-expo-resource-fetcher"
|
||||
import { AudioManager, AudioRecorder } from "react-native-audio-api"
|
||||
import { initWhisper, releaseAllWhisper, type WhisperContext } from "whisper.rn"
|
||||
import { RealtimeTranscriber, type RealtimeTranscribeEvent } from "whisper.rn/src/realtime-transcription"
|
||||
import { AudioPcmStreamAdapter } from "whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter"
|
||||
import { AudioManager } from "react-native-audio-api"
|
||||
import * as Notifications from "expo-notifications"
|
||||
import * as FileSystem from "expo-file-system/legacy"
|
||||
import Constants from "expo-constants"
|
||||
@@ -49,8 +52,6 @@ import {
|
||||
onPushTokenChange,
|
||||
} from "@/notifications/monitoring-notifications"
|
||||
|
||||
const SAMPLE_RATE = 16000
|
||||
const AUDIO_BUFFER_SECONDS = 0.02
|
||||
const CONTROL_HEIGHT = 86
|
||||
const SEND_SETTLE_MS = 240
|
||||
const WAVEFORM_ROWS = 5
|
||||
@@ -61,6 +62,187 @@ const DROPDOWN_VISIBLE_ROWS = 6
|
||||
const TAP_THRESHOLD_MS = 300
|
||||
const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
|
||||
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
|
||||
const WHISPER_SETTINGS_FILE = `${FileSystem.documentDirectory}mobile-voice-whisper-settings.json`
|
||||
const WHISPER_MODELS_DIR = `${FileSystem.documentDirectory}whisper-models`
|
||||
const WHISPER_REPO = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"
|
||||
const WHISPER_MODELS = [
|
||||
"ggml-tiny.en-q5_1.bin",
|
||||
"ggml-tiny.en-q8_0.bin",
|
||||
"ggml-tiny.en.bin",
|
||||
"ggml-tiny-q5_1.bin",
|
||||
"ggml-tiny-q8_0.bin",
|
||||
"ggml-tiny.bin",
|
||||
"ggml-base.en-q5_1.bin",
|
||||
"ggml-base.en-q8_0.bin",
|
||||
"ggml-base.en.bin",
|
||||
"ggml-base-q5_1.bin",
|
||||
"ggml-base-q8_0.bin",
|
||||
"ggml-base.bin",
|
||||
"ggml-small.en-q5_1.bin",
|
||||
"ggml-small.en-q8_0.bin",
|
||||
"ggml-small.en.bin",
|
||||
"ggml-small-q5_1.bin",
|
||||
"ggml-small-q8_0.bin",
|
||||
"ggml-small.bin",
|
||||
"ggml-medium.en-q5_0.bin",
|
||||
"ggml-medium.en-q8_0.bin",
|
||||
"ggml-medium.en.bin",
|
||||
"ggml-medium-q5_0.bin",
|
||||
"ggml-medium-q8_0.bin",
|
||||
"ggml-medium.bin",
|
||||
"ggml-large-v1.bin",
|
||||
"ggml-large-v2-q5_0.bin",
|
||||
"ggml-large-v2-q8_0.bin",
|
||||
"ggml-large-v2.bin",
|
||||
"ggml-large-v3-q5_0.bin",
|
||||
"ggml-large-v3-turbo-q5_0.bin",
|
||||
"ggml-large-v3-turbo-q8_0.bin",
|
||||
"ggml-large-v3-turbo.bin",
|
||||
"ggml-large-v3.bin",
|
||||
] as const
|
||||
|
||||
type WhisperModelID = (typeof WHISPER_MODELS)[number]
|
||||
type TranscriptionMode = "bulk" | "realtime"
|
||||
const DEFAULT_WHISPER_MODEL: WhisperModelID = "ggml-medium.bin"
|
||||
const DEFAULT_TRANSCRIPTION_MODE: TranscriptionMode = "bulk"
|
||||
|
||||
const WHISPER_MODEL_LABELS: Record<WhisperModelID, string> = {
|
||||
"ggml-tiny.en-q5_1.bin": "tiny.en q5_1",
|
||||
"ggml-tiny.en-q8_0.bin": "tiny.en q8_0",
|
||||
"ggml-tiny.en.bin": "tiny.en",
|
||||
"ggml-tiny-q5_1.bin": "tiny q5_1",
|
||||
"ggml-tiny-q8_0.bin": "tiny q8_0",
|
||||
"ggml-tiny.bin": "tiny",
|
||||
"ggml-base.en-q5_1.bin": "base.en q5_1",
|
||||
"ggml-base.en-q8_0.bin": "base.en q8_0",
|
||||
"ggml-base.en.bin": "base.en",
|
||||
"ggml-base-q5_1.bin": "base q5_1",
|
||||
"ggml-base-q8_0.bin": "base q8_0",
|
||||
"ggml-base.bin": "base",
|
||||
"ggml-small.en-q5_1.bin": "small.en q5_1",
|
||||
"ggml-small.en-q8_0.bin": "small.en q8_0",
|
||||
"ggml-small.en.bin": "small.en",
|
||||
"ggml-small-q5_1.bin": "small q5_1",
|
||||
"ggml-small-q8_0.bin": "small q8_0",
|
||||
"ggml-small.bin": "small",
|
||||
"ggml-medium.en-q5_0.bin": "medium.en q5_0",
|
||||
"ggml-medium.en-q8_0.bin": "medium.en q8_0",
|
||||
"ggml-medium.en.bin": "medium.en",
|
||||
"ggml-medium-q5_0.bin": "medium q5_0",
|
||||
"ggml-medium-q8_0.bin": "medium q8_0",
|
||||
"ggml-medium.bin": "medium",
|
||||
"ggml-large-v1.bin": "large-v1",
|
||||
"ggml-large-v2-q5_0.bin": "large-v2 q5_0",
|
||||
"ggml-large-v2-q8_0.bin": "large-v2 q8_0",
|
||||
"ggml-large-v2.bin": "large-v2",
|
||||
"ggml-large-v3-q5_0.bin": "large-v3 q5_0",
|
||||
"ggml-large-v3-turbo-q5_0.bin": "large-v3 turbo q5_0",
|
||||
"ggml-large-v3-turbo-q8_0.bin": "large-v3 turbo q8_0",
|
||||
"ggml-large-v3-turbo.bin": "large-v3 turbo",
|
||||
"ggml-large-v3.bin": "large-v3",
|
||||
}
|
||||
|
||||
const WHISPER_MODEL_SIZES: Record<WhisperModelID, number> = {
|
||||
"ggml-tiny.en-q5_1.bin": 32166155,
|
||||
"ggml-tiny.en-q8_0.bin": 43550795,
|
||||
"ggml-tiny.en.bin": 77704715,
|
||||
"ggml-tiny-q5_1.bin": 32152673,
|
||||
"ggml-tiny-q8_0.bin": 43537433,
|
||||
"ggml-tiny.bin": 77691713,
|
||||
"ggml-base.en-q5_1.bin": 59721011,
|
||||
"ggml-base.en-q8_0.bin": 81781811,
|
||||
"ggml-base.en.bin": 147964211,
|
||||
"ggml-base-q5_1.bin": 59707625,
|
||||
"ggml-base-q8_0.bin": 81768585,
|
||||
"ggml-base.bin": 147951465,
|
||||
"ggml-small.en-q5_1.bin": 190098681,
|
||||
"ggml-small.en-q8_0.bin": 264477561,
|
||||
"ggml-small.en.bin": 487614201,
|
||||
"ggml-small-q5_1.bin": 190085487,
|
||||
"ggml-small-q8_0.bin": 264464607,
|
||||
"ggml-small.bin": 487601967,
|
||||
"ggml-medium.en-q5_0.bin": 539225533,
|
||||
"ggml-medium.en-q8_0.bin": 823382461,
|
||||
"ggml-medium.en.bin": 1533774781,
|
||||
"ggml-medium-q5_0.bin": 539212467,
|
||||
"ggml-medium-q8_0.bin": 823369779,
|
||||
"ggml-medium.bin": 1533763059,
|
||||
"ggml-large-v1.bin": 3094623691,
|
||||
"ggml-large-v2-q5_0.bin": 1080732091,
|
||||
"ggml-large-v2-q8_0.bin": 1656129691,
|
||||
"ggml-large-v2.bin": 3094623691,
|
||||
"ggml-large-v3-q5_0.bin": 1081140203,
|
||||
"ggml-large-v3-turbo-q5_0.bin": 574041195,
|
||||
"ggml-large-v3-turbo-q8_0.bin": 874188075,
|
||||
"ggml-large-v3-turbo.bin": 1624555275,
|
||||
"ggml-large-v3.bin": 3095033483,
|
||||
}
|
||||
|
||||
function isWhisperModelID(value: unknown): value is WhisperModelID {
|
||||
return typeof value === "string" && (WHISPER_MODELS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
function isEnglishOnlyWhisperModel(modelID: WhisperModelID): boolean {
|
||||
return modelID.includes(".en")
|
||||
}
|
||||
|
||||
function isTranscriptionMode(value: unknown): value is TranscriptionMode {
|
||||
return value === "bulk" || value === "realtime"
|
||||
}
|
||||
|
||||
function formatWhisperModelSize(bytes: number): string {
|
||||
const mib = bytes / (1024 * 1024)
|
||||
if (mib >= 1024) {
|
||||
return `${(mib / 1024).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
return `${Math.round(mib)} MB`
|
||||
}
|
||||
|
||||
function cleanTranscriptText(text: string): string {
|
||||
return text.replace(/[ \t]+$/gm, "").trimEnd()
|
||||
}
|
||||
|
||||
function cleanSessionText(text: string): string {
|
||||
return cleanTranscriptText(text).trimStart()
|
||||
}
|
||||
|
||||
function normalizeTranscriptSessions(text: string): string {
|
||||
const cleaned = cleanTranscriptText(text)
|
||||
if (!cleaned) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cleaned
|
||||
.split(/\n\n+/)
|
||||
.map((session) => cleanSessionText(session))
|
||||
.filter((session) => session.length > 0)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
function mergeTranscriptChunk(previous: string, chunk: string): string {
|
||||
const cleanPrevious = cleanTranscriptText(previous)
|
||||
const cleanChunk = cleanSessionText(chunk)
|
||||
|
||||
if (!cleanChunk) {
|
||||
return cleanPrevious
|
||||
}
|
||||
|
||||
if (!cleanPrevious) {
|
||||
return cleanChunk
|
||||
}
|
||||
|
||||
const normalizedChunk = cleanChunk
|
||||
if (!normalizedChunk) {
|
||||
return cleanPrevious
|
||||
}
|
||||
|
||||
if (/^[,.;:!?)]/.test(normalizedChunk)) {
|
||||
return `${cleanPrevious}${normalizedChunk}`
|
||||
}
|
||||
|
||||
return `${cleanPrevious} ${normalizedChunk}`
|
||||
}
|
||||
|
||||
type ServerItem = {
|
||||
id: string
|
||||
@@ -135,6 +317,11 @@ type SavedState = {
|
||||
activeSessionId: string | null
|
||||
}
|
||||
|
||||
type WhisperSavedState = {
|
||||
defaultModel: WhisperModelID
|
||||
mode: TranscriptionMode
|
||||
}
|
||||
|
||||
type Cam = {
|
||||
CameraView: (typeof import("expo-camera"))["CameraView"]
|
||||
requestCameraPermissionsAsync: (typeof import("expo-camera"))["Camera"]["requestCameraPermissionsAsync"]
|
||||
@@ -245,12 +432,16 @@ function fromSaved(input: SavedState): {
|
||||
|
||||
export default function DictationScreen() {
|
||||
const [camera, setCamera] = useState<Cam | null>(null)
|
||||
const [modelReset, setModelReset] = useState(false)
|
||||
const model = useSpeechToText({
|
||||
model: WHISPER_BASE_EN,
|
||||
preventLoad: modelReset,
|
||||
})
|
||||
|
||||
const [defaultWhisperModel, setDefaultWhisperModel] = useState<WhisperModelID>(DEFAULT_WHISPER_MODEL)
|
||||
const [activeWhisperModel, setActiveWhisperModel] = useState<WhisperModelID | null>(null)
|
||||
const [installedWhisperModels, setInstalledWhisperModels] = useState<WhisperModelID[]>([])
|
||||
const [whisperSettingsOpen, setWhisperSettingsOpen] = useState(false)
|
||||
const [downloadingModelID, setDownloadingModelID] = useState<WhisperModelID | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [isPreparingWhisperModel, setIsPreparingWhisperModel] = useState(true)
|
||||
const [transcriptionMode, setTranscriptionMode] = useState<TranscriptionMode>(DEFAULT_TRANSCRIPTION_MODE)
|
||||
const [isTranscribingBulk, setIsTranscribingBulk] = useState(false)
|
||||
const [whisperError, setWhisperError] = useState("")
|
||||
const [transcribedText, setTranscribedText] = useState("")
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [permissionGranted, setPermissionGranted] = useState(false)
|
||||
@@ -283,21 +474,23 @@ export default function DictationScreen() {
|
||||
const pressInTimeRef = useRef(0)
|
||||
const accumulatedRef = useRef("")
|
||||
const baseTextRef = useRef("")
|
||||
// Keep a ref to model so audio callbacks always use the latest hook closure
|
||||
const modelRef = useRef(model)
|
||||
modelRef.current = model
|
||||
const prewarmPromiseRef = useRef<Promise<void> | null>(null)
|
||||
const hasPrewarmedRef = useRef(false)
|
||||
const whisperContextRef = useRef<WhisperContext | null>(null)
|
||||
const whisperContextModelRef = useRef<WhisperModelID | null>(null)
|
||||
const whisperTranscriberRef = useRef<RealtimeTranscriber | null>(null)
|
||||
const bulkAudioStreamRef = useRef<AudioPcmStreamAdapter | null>(null)
|
||||
const bulkAudioChunksRef = useRef<Uint8Array[]>([])
|
||||
const bulkTranscriptionJobRef = useRef(0)
|
||||
const downloadProgressRef = useRef(0)
|
||||
const waveformPulseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
|
||||
const monitorJobRef = useRef<MonitorJob | null>(null)
|
||||
const previousPushTokenRef = useRef<string | null>(null)
|
||||
const scanLockRef = useRef(false)
|
||||
const restoredRef = useRef(false)
|
||||
const whisperRestoredRef = useRef(false)
|
||||
const refreshSeqRef = useRef<Record<string, number>>({})
|
||||
|
||||
const [recorder] = useState(() => new AudioRecorder())
|
||||
|
||||
useEffect(() => {
|
||||
serversRef.current = servers
|
||||
}, [servers])
|
||||
@@ -338,12 +531,38 @@ export default function DictationScreen() {
|
||||
monitorJobRef.current = monitorJob
|
||||
}, [monitorJob])
|
||||
|
||||
const ensureAudioRoute = useCallback(async () => {
|
||||
await AudioManager.setAudioSessionActivity(true)
|
||||
const devices = await AudioManager.getDevicesInfo()
|
||||
if (devices.currentInputs.length === 0 && devices.availableInputs.length > 0) {
|
||||
await AudioManager.setInputDevice(devices.availableInputs[0].id)
|
||||
const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, [])
|
||||
|
||||
const refreshInstalledWhisperModels = useCallback(async () => {
|
||||
const next: WhisperModelID[] = []
|
||||
|
||||
for (const modelID of WHISPER_MODELS) {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(modelPath(modelID))
|
||||
if (info.exists) {
|
||||
next.push(modelID)
|
||||
}
|
||||
} catch {
|
||||
// Ignore model metadata read errors.
|
||||
}
|
||||
}
|
||||
|
||||
setInstalledWhisperModels(next)
|
||||
return next
|
||||
}, [modelPath])
|
||||
|
||||
const stopWaveformPulse = useCallback(() => {
|
||||
if (waveformPulseIntervalRef.current) {
|
||||
clearInterval(waveformPulseIntervalRef.current)
|
||||
waveformPulseIntervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearWaveform = useCallback(() => {
|
||||
const cleared = new Array(waveformLevelsRef.current.length).fill(0)
|
||||
waveformLevelsRef.current = cleared
|
||||
setWaveformLevels(cleared)
|
||||
setWaveformTick(Date.now())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -351,23 +570,9 @@ export default function DictationScreen() {
|
||||
if (sendSettleTimeoutRef.current) {
|
||||
clearTimeout(sendSettleTimeoutRef.current)
|
||||
}
|
||||
stopWaveformPulse()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Warm up the model once after load to reduce first-utterance latency.
|
||||
useEffect(() => {
|
||||
if (!model.isReady || hasPrewarmedRef.current) return
|
||||
hasPrewarmedRef.current = true
|
||||
prewarmPromiseRef.current = (async () => {
|
||||
try {
|
||||
await modelRef.current.transcribe(new Float32Array(SAMPLE_RATE / 2), {
|
||||
verbose: false,
|
||||
})
|
||||
} catch {
|
||||
// Prewarm best-effort only.
|
||||
}
|
||||
})()
|
||||
}, [model.isReady])
|
||||
}, [stopWaveformPulse])
|
||||
|
||||
// Set up audio session and request permissions on mount
|
||||
useEffect(() => {
|
||||
@@ -411,6 +616,215 @@ export default function DictationScreen() {
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const loadWhisperContext = useCallback(
|
||||
async (modelID: WhisperModelID) => {
|
||||
if (whisperContextRef.current && whisperContextModelRef.current === modelID) {
|
||||
setActiveWhisperModel(modelID)
|
||||
return whisperContextRef.current
|
||||
}
|
||||
|
||||
setIsPreparingWhisperModel(true)
|
||||
setWhisperError("")
|
||||
|
||||
try {
|
||||
const existing = whisperContextRef.current
|
||||
whisperContextRef.current = null
|
||||
whisperContextModelRef.current = null
|
||||
if (existing) {
|
||||
await existing.release().catch(() => {})
|
||||
}
|
||||
|
||||
const context = await initWhisper({
|
||||
filePath: modelPath(modelID),
|
||||
useGpu: Platform.OS === "ios",
|
||||
})
|
||||
|
||||
whisperContextRef.current = context
|
||||
whisperContextModelRef.current = modelID
|
||||
setActiveWhisperModel(modelID)
|
||||
return context
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load Whisper model"
|
||||
setWhisperError(message)
|
||||
throw error
|
||||
} finally {
|
||||
setIsPreparingWhisperModel(false)
|
||||
}
|
||||
},
|
||||
[modelPath],
|
||||
)
|
||||
|
||||
const downloadWhisperModel = useCallback(
|
||||
async (modelID: WhisperModelID) => {
|
||||
if (downloadingModelID && downloadingModelID !== modelID) {
|
||||
return false
|
||||
}
|
||||
|
||||
setDownloadingModelID(modelID)
|
||||
downloadProgressRef.current = 0
|
||||
setDownloadProgress(0)
|
||||
setWhisperError("")
|
||||
|
||||
try {
|
||||
await FileSystem.makeDirectoryAsync(WHISPER_MODELS_DIR, { intermediates: true }).catch(() => {})
|
||||
|
||||
const targetPath = modelPath(modelID)
|
||||
await FileSystem.deleteAsync(targetPath, { idempotent: true }).catch(() => {})
|
||||
|
||||
const download = FileSystem.createDownloadResumable(
|
||||
`${WHISPER_REPO}/${modelID}`,
|
||||
targetPath,
|
||||
{},
|
||||
(event: FileSystem.DownloadProgressData) => {
|
||||
const total = event.totalBytesExpectedToWrite
|
||||
if (!total) return
|
||||
const rawProgress = Math.max(0, Math.min(1, event.totalBytesWritten / total))
|
||||
const progress = Math.max(downloadProgressRef.current, rawProgress)
|
||||
downloadProgressRef.current = progress
|
||||
setDownloadProgress(progress)
|
||||
},
|
||||
)
|
||||
|
||||
const result = await download.downloadAsync()
|
||||
if (!result?.uri) {
|
||||
throw new Error("Whisper model download did not complete")
|
||||
}
|
||||
|
||||
await refreshInstalledWhisperModels()
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to download Whisper model"
|
||||
setWhisperError(message)
|
||||
return false
|
||||
} finally {
|
||||
setDownloadingModelID((current) => (current === modelID ? null : current))
|
||||
}
|
||||
},
|
||||
[downloadingModelID, modelPath, refreshInstalledWhisperModels],
|
||||
)
|
||||
|
||||
const ensureWhisperModelReady = useCallback(
|
||||
async (modelID: WhisperModelID) => {
|
||||
const info = await FileSystem.getInfoAsync(modelPath(modelID))
|
||||
if (!info.exists) {
|
||||
const downloaded = await downloadWhisperModel(modelID)
|
||||
if (!downloaded) {
|
||||
throw new Error(`Unable to download ${modelID}`)
|
||||
}
|
||||
}
|
||||
return loadWhisperContext(modelID)
|
||||
},
|
||||
[downloadWhisperModel, loadWhisperContext, modelPath],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
;(async () => {
|
||||
await FileSystem.makeDirectoryAsync(WHISPER_MODELS_DIR, { intermediates: true }).catch(() => {})
|
||||
|
||||
let nextDefaultModel: WhisperModelID = DEFAULT_WHISPER_MODEL
|
||||
let nextMode: TranscriptionMode = DEFAULT_TRANSCRIPTION_MODE
|
||||
try {
|
||||
const data = await FileSystem.readAsStringAsync(WHISPER_SETTINGS_FILE)
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data) as Partial<WhisperSavedState>
|
||||
if (isWhisperModelID(parsed.defaultModel)) {
|
||||
nextDefaultModel = parsed.defaultModel
|
||||
}
|
||||
if (isTranscriptionMode(parsed.mode)) {
|
||||
nextMode = parsed.mode
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Use default settings if state file is missing or invalid.
|
||||
}
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
whisperRestoredRef.current = true
|
||||
setDefaultWhisperModel(nextDefaultModel)
|
||||
setTranscriptionMode(nextMode)
|
||||
|
||||
await refreshInstalledWhisperModels()
|
||||
|
||||
try {
|
||||
await ensureWhisperModelReady(nextDefaultModel)
|
||||
} catch (error) {
|
||||
console.error("[Whisper] Failed to initialize default model:", error)
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsPreparingWhisperModel(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [ensureWhisperModelReady, refreshInstalledWhisperModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (!whisperRestoredRef.current) return
|
||||
const payload: WhisperSavedState = { defaultModel: defaultWhisperModel, mode: transcriptionMode }
|
||||
FileSystem.writeAsStringAsync(WHISPER_SETTINGS_FILE, JSON.stringify(payload)).catch(() => {})
|
||||
}, [defaultWhisperModel, transcriptionMode])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const transcriber = whisperTranscriberRef.current
|
||||
whisperTranscriberRef.current = null
|
||||
if (transcriber) {
|
||||
void (async () => {
|
||||
await transcriber.stop().catch(() => {})
|
||||
await transcriber.release().catch(() => {})
|
||||
})()
|
||||
}
|
||||
|
||||
const bulkStream = bulkAudioStreamRef.current
|
||||
bulkAudioStreamRef.current = null
|
||||
if (bulkStream) {
|
||||
void (async () => {
|
||||
await bulkStream.stop().catch(() => {})
|
||||
await bulkStream.release().catch(() => {})
|
||||
})()
|
||||
}
|
||||
|
||||
const context = whisperContextRef.current
|
||||
whisperContextRef.current = null
|
||||
whisperContextModelRef.current = null
|
||||
|
||||
if (context) {
|
||||
context.release().catch(() => {})
|
||||
}
|
||||
|
||||
releaseAllWhisper().catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startWaveformPulse = useCallback(() => {
|
||||
if (waveformPulseIntervalRef.current) return
|
||||
|
||||
waveformPulseIntervalRef.current = setInterval(() => {
|
||||
if (!isRecordingRef.current) return
|
||||
|
||||
const next = waveformLevelsRef.current.map((value) => {
|
||||
const decay = value * 0.45
|
||||
const lift = Math.random() * 0.95
|
||||
return Math.max(0.08, Math.min(1, decay + lift * 0.55))
|
||||
})
|
||||
|
||||
waveformLevelsRef.current = next
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastWaveformCommitRef.current > 45) {
|
||||
setWaveformLevels(next)
|
||||
setWaveformTick(now)
|
||||
lastWaveformCommitRef.current = now
|
||||
}
|
||||
}, 70)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener("change", (nextState) => {
|
||||
setAppState(nextState)
|
||||
@@ -463,192 +877,296 @@ export default function DictationScreen() {
|
||||
return () => notificationSub.remove()
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
const m = modelRef.current
|
||||
if (!m.isReady || isRecordingRef.current || isStartingRef.current) return
|
||||
|
||||
isStartingRef.current = true
|
||||
const sessionId = Date.now()
|
||||
activeSessionRef.current = sessionId
|
||||
accumulatedRef.current = ""
|
||||
baseTextRef.current = transcribedText
|
||||
isRecordingRef.current = true
|
||||
setIsRecording(true)
|
||||
const cancelled = () => !isRecordingRef.current || activeSessionRef.current !== sessionId
|
||||
|
||||
// If prewarm is still running, wait once here to avoid ModelGenerating race.
|
||||
if (prewarmPromiseRef.current) {
|
||||
await prewarmPromiseRef.current
|
||||
prewarmPromiseRef.current = null
|
||||
}
|
||||
if (cancelled()) {
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAudioRoute()
|
||||
} catch (e) {
|
||||
console.warn("[Dictation] Failed to ensure audio route:", e)
|
||||
}
|
||||
if (cancelled()) {
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
recorder.onError((err) => {
|
||||
console.error("[Dictation] Recorder error:", err.message)
|
||||
if (activeSessionRef.current !== sessionId) return
|
||||
isRecordingRef.current = false
|
||||
activeSessionRef.current = 0
|
||||
setIsRecording(false)
|
||||
recorder.clearOnAudioReady()
|
||||
recorder.clearOnError()
|
||||
modelRef.current.streamStop()
|
||||
})
|
||||
|
||||
const readyResult = recorder.onAudioReady(
|
||||
{
|
||||
sampleRate: SAMPLE_RATE,
|
||||
bufferLength: AUDIO_BUFFER_SECONDS * SAMPLE_RATE,
|
||||
channelCount: 1,
|
||||
},
|
||||
(chunk) => {
|
||||
if (activeSessionRef.current !== sessionId) return
|
||||
const samples = chunk.buffer.getChannelData(0)
|
||||
if (!samples || samples.length === 0) return
|
||||
|
||||
// Defensive guard against invalid chunk data coming from unstable audio routes.
|
||||
let valid = true
|
||||
for (let i = 0; i < samples.length; i += 32) {
|
||||
if (!Number.isFinite(samples[i])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!valid) return
|
||||
|
||||
const columns = waveformLevelsRef.current.length
|
||||
const segmentLength = Math.max(1, Math.floor(samples.length / Math.max(columns, 1)))
|
||||
const next = new Array(columns).fill(0)
|
||||
|
||||
for (let b = 0; b < columns; b++) {
|
||||
const start = b * segmentLength
|
||||
const end = Math.min(samples.length, start + segmentLength)
|
||||
|
||||
let sum = 0
|
||||
for (let i = start; i < end; i++) {
|
||||
const s = samples[i]
|
||||
sum += s * s
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sum / Math.max(end - start, 1))
|
||||
const base = Math.min(1, rms * 10)
|
||||
const previous = waveformLevelsRef.current[b] ?? 0
|
||||
// Fast rise, slower decay for more natural meter behavior
|
||||
next[b] = base > previous ? base : previous * 0.82
|
||||
}
|
||||
|
||||
waveformLevelsRef.current = next
|
||||
const now = Date.now()
|
||||
if (now - lastWaveformCommitRef.current > 45) {
|
||||
setWaveformLevels(next)
|
||||
setWaveformTick(now)
|
||||
lastWaveformCommitRef.current = now
|
||||
}
|
||||
|
||||
// Always use the latest model ref to avoid stale closure.
|
||||
modelRef.current.streamInsert(samples)
|
||||
},
|
||||
)
|
||||
|
||||
if (readyResult.status === "error") {
|
||||
console.error("[Dictation] onAudioReady failed:", readyResult.message)
|
||||
isRecordingRef.current = false
|
||||
activeSessionRef.current = 0
|
||||
setIsRecording(false)
|
||||
recorder.clearOnAudioReady()
|
||||
recorder.clearOnError()
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
if (cancelled()) {
|
||||
recorder.clearOnAudioReady()
|
||||
recorder.clearOnError()
|
||||
modelRef.current.streamStop()
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// Start stream first, then begin feeding chunks from recorder.
|
||||
const streamIter = modelRef.current.stream({ verbose: false })
|
||||
let sawTextInSession = false
|
||||
const streamTask = (async () => {
|
||||
for await (const { committed, nonCommitted } of streamIter) {
|
||||
if (!isRecordingRef.current) break
|
||||
|
||||
if (committed.text) {
|
||||
accumulatedRef.current += committed.text
|
||||
}
|
||||
|
||||
if (committed.text || nonCommitted.text) {
|
||||
sawTextInSession = true
|
||||
}
|
||||
|
||||
const base = baseTextRef.current
|
||||
const separator = base.length > 0 ? "\n\n" : ""
|
||||
// Whisper can emit a leading-space token at the start of each session.
|
||||
const sessionText = (accumulatedRef.current + nonCommitted.text).replace(/^\s+/, "")
|
||||
setTranscribedText(base + separator + sessionText)
|
||||
}
|
||||
})()
|
||||
|
||||
const startResult = recorder.start()
|
||||
if (startResult.status === "error") {
|
||||
console.error("[Dictation] Recorder start failed:", startResult.message)
|
||||
modelRef.current.streamStop()
|
||||
isRecordingRef.current = false
|
||||
activeSessionRef.current = 0
|
||||
setIsRecording(false)
|
||||
recorder.clearOnAudioReady()
|
||||
recorder.clearOnError()
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
isStartingRef.current = false
|
||||
|
||||
try {
|
||||
await streamTask
|
||||
if (sawTextInSession) {
|
||||
setHasCompletedSession(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Dictation] Streaming error:", error)
|
||||
}
|
||||
}, [ensureAudioRoute, recorder, transcribedText])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (!isRecordingRef.current) return
|
||||
|
||||
const finalizeRecordingState = useCallback(() => {
|
||||
isRecordingRef.current = false
|
||||
activeSessionRef.current = 0
|
||||
isStartingRef.current = false
|
||||
setIsRecording(false)
|
||||
stopWaveformPulse()
|
||||
clearWaveform()
|
||||
}, [clearWaveform, stopWaveformPulse])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (isRecordingRef.current || isStartingRef.current || downloadingModelID || isTranscribingBulk) return
|
||||
|
||||
isStartingRef.current = true
|
||||
const sessionID = Date.now()
|
||||
activeSessionRef.current = sessionID
|
||||
accumulatedRef.current = ""
|
||||
baseTextRef.current = normalizeTranscriptSessions(transcribedText)
|
||||
if (baseTextRef.current !== transcribedText) {
|
||||
setTranscribedText(baseTextRef.current)
|
||||
}
|
||||
isRecordingRef.current = true
|
||||
setIsRecording(true)
|
||||
setWhisperError("")
|
||||
|
||||
const cancelled = () => !isRecordingRef.current || activeSessionRef.current !== sessionID
|
||||
|
||||
try {
|
||||
const context = await ensureWhisperModelReady(defaultWhisperModel)
|
||||
if (cancelled()) {
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const previousTranscriber = whisperTranscriberRef.current
|
||||
whisperTranscriberRef.current = null
|
||||
if (previousTranscriber) {
|
||||
await previousTranscriber.stop().catch(() => {})
|
||||
await previousTranscriber.release().catch(() => {})
|
||||
}
|
||||
|
||||
const previousBulkStream = bulkAudioStreamRef.current
|
||||
bulkAudioStreamRef.current = null
|
||||
if (previousBulkStream) {
|
||||
await previousBulkStream.stop().catch(() => {})
|
||||
await previousBulkStream.release().catch(() => {})
|
||||
}
|
||||
|
||||
bulkAudioChunksRef.current = []
|
||||
bulkTranscriptionJobRef.current = 0
|
||||
|
||||
startWaveformPulse()
|
||||
|
||||
const englishOnlyModel = isEnglishOnlyWhisperModel(defaultWhisperModel)
|
||||
|
||||
if (transcriptionMode === "bulk") {
|
||||
const audioStream = new AudioPcmStreamAdapter()
|
||||
audioStream.onData((packet: unknown) => {
|
||||
if (activeSessionRef.current !== sessionID) return
|
||||
const data = (packet as { data?: unknown }).data
|
||||
if (!(data instanceof Uint8Array) || data.length === 0) return
|
||||
bulkAudioChunksRef.current.push(new Uint8Array(data))
|
||||
})
|
||||
audioStream.onError((error: string) => {
|
||||
if (activeSessionRef.current !== sessionID) return
|
||||
setWhisperError(error)
|
||||
console.error("[Dictation] Bulk audio stream error:", error)
|
||||
})
|
||||
|
||||
await audioStream.initialize({
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
bitsPerSample: 16,
|
||||
bufferSize: 16 * 1024,
|
||||
audioSource: 6,
|
||||
})
|
||||
await audioStream.start()
|
||||
|
||||
bulkAudioStreamRef.current = audioStream
|
||||
|
||||
if (cancelled()) {
|
||||
await audioStream.stop().catch(() => {})
|
||||
await audioStream.release().catch(() => {})
|
||||
if (bulkAudioStreamRef.current === audioStream) {
|
||||
bulkAudioStreamRef.current = null
|
||||
}
|
||||
finalizeRecordingState()
|
||||
return
|
||||
}
|
||||
|
||||
isStartingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const transcriber = new RealtimeTranscriber(
|
||||
{
|
||||
whisperContext: context,
|
||||
audioStream: new AudioPcmStreamAdapter(),
|
||||
},
|
||||
{
|
||||
audioSliceSec: 4,
|
||||
audioMinSec: 0.8,
|
||||
maxSlicesInMemory: 6,
|
||||
transcribeOptions: {
|
||||
language: englishOnlyModel ? "en" : "auto",
|
||||
translate: !englishOnlyModel,
|
||||
maxLen: 1,
|
||||
},
|
||||
logger: () => {},
|
||||
},
|
||||
{
|
||||
onTranscribe: (event: RealtimeTranscribeEvent) => {
|
||||
if (activeSessionRef.current !== sessionID) return
|
||||
if (event.type !== "transcribe") return
|
||||
|
||||
const nextSessionText = mergeTranscriptChunk(accumulatedRef.current, event.data?.result ?? "")
|
||||
accumulatedRef.current = nextSessionText
|
||||
|
||||
const base = normalizeTranscriptSessions(baseTextRef.current)
|
||||
const separator = base.length > 0 && nextSessionText.length > 0 ? "\n\n" : ""
|
||||
setTranscribedText(normalizeTranscriptSessions(base + separator + nextSessionText))
|
||||
|
||||
if (nextSessionText.length > 0) {
|
||||
setHasCompletedSession(true)
|
||||
}
|
||||
},
|
||||
onError: (error: string) => {
|
||||
if (activeSessionRef.current !== sessionID) return
|
||||
console.error("[Dictation] Whisper realtime error:", error)
|
||||
setWhisperError(error)
|
||||
},
|
||||
onStatusChange: (active: boolean) => {
|
||||
if (activeSessionRef.current !== sessionID) return
|
||||
if (!active) {
|
||||
if (whisperTranscriberRef.current === transcriber) {
|
||||
whisperTranscriberRef.current = null
|
||||
}
|
||||
finalizeRecordingState()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
whisperTranscriberRef.current = transcriber
|
||||
await transcriber.start()
|
||||
|
||||
if (cancelled()) {
|
||||
await transcriber.stop().catch(() => {})
|
||||
await transcriber.release().catch(() => {})
|
||||
if (whisperTranscriberRef.current === transcriber) {
|
||||
whisperTranscriberRef.current = null
|
||||
}
|
||||
finalizeRecordingState()
|
||||
return
|
||||
}
|
||||
|
||||
isStartingRef.current = false
|
||||
} catch (error) {
|
||||
console.error("[Dictation] Failed to start realtime transcription:", error)
|
||||
const message = error instanceof Error ? error.message : "Unable to start transcription"
|
||||
setWhisperError(message)
|
||||
|
||||
const activeTranscriber = whisperTranscriberRef.current
|
||||
whisperTranscriberRef.current = null
|
||||
if (activeTranscriber) {
|
||||
void (async () => {
|
||||
await activeTranscriber.stop().catch(() => {})
|
||||
await activeTranscriber.release().catch(() => {})
|
||||
})()
|
||||
}
|
||||
|
||||
finalizeRecordingState()
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
|
||||
}
|
||||
}, [
|
||||
defaultWhisperModel,
|
||||
downloadingModelID,
|
||||
ensureWhisperModelReady,
|
||||
finalizeRecordingState,
|
||||
isTranscribingBulk,
|
||||
startWaveformPulse,
|
||||
transcriptionMode,
|
||||
transcribedText,
|
||||
])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (!isRecordingRef.current && !isStartingRef.current) return
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
|
||||
recorder.stop()
|
||||
recorder.clearOnAudioReady()
|
||||
recorder.clearOnError()
|
||||
modelRef.current.streamStop()
|
||||
const cleared = new Array(waveformLevelsRef.current.length).fill(0)
|
||||
waveformLevelsRef.current = cleared
|
||||
setWaveformLevels(cleared)
|
||||
setWaveformTick(Date.now())
|
||||
}, [recorder])
|
||||
|
||||
const baseAtStop = normalizeTranscriptSessions(baseTextRef.current)
|
||||
const englishOnlyModel = isEnglishOnlyWhisperModel(defaultWhisperModel)
|
||||
|
||||
const transcriber = whisperTranscriberRef.current
|
||||
whisperTranscriberRef.current = null
|
||||
if (transcriber) {
|
||||
void (async () => {
|
||||
await transcriber.stop().catch((error: unknown) => {
|
||||
console.warn("[Dictation] Failed to stop realtime transcription:", error)
|
||||
})
|
||||
await transcriber.release().catch(() => {})
|
||||
})()
|
||||
}
|
||||
|
||||
const bulkStream = bulkAudioStreamRef.current
|
||||
bulkAudioStreamRef.current = null
|
||||
const bulkChunks = bulkAudioChunksRef.current
|
||||
bulkAudioChunksRef.current = []
|
||||
|
||||
finalizeRecordingState()
|
||||
|
||||
if (transcriptionMode !== "bulk") {
|
||||
return
|
||||
}
|
||||
|
||||
const runID = Date.now()
|
||||
bulkTranscriptionJobRef.current = runID
|
||||
|
||||
void (async () => {
|
||||
if (bulkStream) {
|
||||
await bulkStream.stop().catch((error: unknown) => {
|
||||
console.warn("[Dictation] Failed to stop bulk audio stream:", error)
|
||||
})
|
||||
await bulkStream.release().catch(() => {})
|
||||
}
|
||||
|
||||
if (bulkChunks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const totalLength = bulkChunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
if (totalLength === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of bulkChunks) {
|
||||
merged.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
const context = whisperContextRef.current
|
||||
if (!context) {
|
||||
setWhisperError("Whisper model is not loaded")
|
||||
return
|
||||
}
|
||||
|
||||
setIsTranscribingBulk(true)
|
||||
|
||||
try {
|
||||
const { promise } = context.transcribeData(merged.buffer, {
|
||||
language: englishOnlyModel ? "en" : "auto",
|
||||
translate: !englishOnlyModel,
|
||||
maxLen: 1,
|
||||
})
|
||||
|
||||
const result = await promise
|
||||
if (bulkTranscriptionJobRef.current !== runID) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionText = cleanSessionText(result.result ?? "")
|
||||
if (!sessionText) {
|
||||
return
|
||||
}
|
||||
|
||||
const separator = baseAtStop.length > 0 ? "\n\n" : ""
|
||||
setTranscribedText(normalizeTranscriptSessions(baseAtStop + separator + sessionText))
|
||||
setHasCompletedSession(true)
|
||||
} catch (error) {
|
||||
if (bulkTranscriptionJobRef.current !== runID) {
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Bulk transcription failed"
|
||||
setWhisperError(message)
|
||||
console.error("[Dictation] Bulk transcription failed:", error)
|
||||
} finally {
|
||||
if (bulkTranscriptionJobRef.current === runID) {
|
||||
setIsTranscribingBulk(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [defaultWhisperModel, finalizeRecordingState, transcriptionMode])
|
||||
|
||||
const clearIconRotation = useSharedValue(0)
|
||||
const sendOutProgress = useSharedValue(0)
|
||||
|
||||
const handleClearTranscript = useCallback(() => {
|
||||
Haptics.selectionAsync().catch(() => {})
|
||||
|
||||
clearIconRotation.value = withSequence(
|
||||
withTiming(-30, { duration: 90 }),
|
||||
withTiming(30, { duration: 120 }),
|
||||
@@ -662,42 +1180,10 @@ export default function DictationScreen() {
|
||||
baseTextRef.current = ""
|
||||
setTranscribedText("")
|
||||
setHasCompletedSession(false)
|
||||
const cleared = new Array(waveformLevelsRef.current.length).fill(0)
|
||||
waveformLevelsRef.current = cleared
|
||||
setWaveformLevels(cleared)
|
||||
setWaveformTick(Date.now())
|
||||
clearWaveform()
|
||||
sendOutProgress.value = 0
|
||||
setIsSending(false)
|
||||
}, [clearIconRotation, sendOutProgress, stopRecording])
|
||||
|
||||
const handleDeleteModel = useCallback(async () => {
|
||||
if (modelReset) return
|
||||
|
||||
if (isRecordingRef.current) {
|
||||
stopRecording()
|
||||
}
|
||||
|
||||
setModelReset(true)
|
||||
accumulatedRef.current = ""
|
||||
baseTextRef.current = ""
|
||||
setTranscribedText("")
|
||||
setHasCompletedSession(false)
|
||||
const cleared = new Array(waveformLevelsRef.current.length).fill(0)
|
||||
waveformLevelsRef.current = cleared
|
||||
setWaveformLevels(cleared)
|
||||
setWaveformTick(Date.now())
|
||||
sendOutProgress.value = 0
|
||||
setIsSending(false)
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
|
||||
try {
|
||||
await ExpoResourceFetcher.deleteResources(WHISPER_BASE_EN.modelSource, WHISPER_BASE_EN.tokenizerSource)
|
||||
} catch (err) {
|
||||
console.error("Failed to delete model resources:", err)
|
||||
}
|
||||
|
||||
setModelReset(false)
|
||||
}, [modelReset, sendOutProgress, stopRecording])
|
||||
}, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording])
|
||||
|
||||
const resetTranscriptState = useCallback(() => {
|
||||
if (isRecordingRef.current) {
|
||||
@@ -707,11 +1193,91 @@ export default function DictationScreen() {
|
||||
baseTextRef.current = ""
|
||||
setTranscribedText("")
|
||||
setHasCompletedSession(false)
|
||||
const cleared = new Array(waveformLevelsRef.current.length).fill(0)
|
||||
waveformLevelsRef.current = cleared
|
||||
setWaveformLevels(cleared)
|
||||
setWaveformTick(Date.now())
|
||||
}, [stopRecording])
|
||||
clearWaveform()
|
||||
}, [clearWaveform, stopRecording])
|
||||
|
||||
const handleOpenWhisperSettings = useCallback(() => {
|
||||
Haptics.selectionAsync().catch(() => {})
|
||||
setDropdownMode("none")
|
||||
setWhisperSettingsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDownloadWhisperModel = useCallback(
|
||||
async (modelID: WhisperModelID) => {
|
||||
const ok = await downloadWhisperModel(modelID)
|
||||
if (ok) {
|
||||
Haptics.selectionAsync().catch(() => {})
|
||||
}
|
||||
},
|
||||
[downloadWhisperModel],
|
||||
)
|
||||
|
||||
const handleSelectWhisperModel = useCallback(
|
||||
async (modelID: WhisperModelID) => {
|
||||
if (isRecordingRef.current || isStartingRef.current) {
|
||||
stopRecording()
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureWhisperModelReady(modelID)
|
||||
setDefaultWhisperModel(modelID)
|
||||
setWhisperError("")
|
||||
Haptics.selectionAsync().catch(() => {})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to switch Whisper model"
|
||||
setWhisperError(message)
|
||||
}
|
||||
},
|
||||
[ensureWhisperModelReady, stopRecording],
|
||||
)
|
||||
|
||||
const handleDeleteWhisperModel = useCallback(
|
||||
async (modelID: WhisperModelID) => {
|
||||
if (downloadingModelID === modelID) return
|
||||
|
||||
if (isRecordingRef.current || isStartingRef.current) {
|
||||
stopRecording()
|
||||
}
|
||||
|
||||
if (whisperContextModelRef.current === modelID && whisperContextRef.current) {
|
||||
const activeContext = whisperContextRef.current
|
||||
whisperContextRef.current = null
|
||||
whisperContextModelRef.current = null
|
||||
setActiveWhisperModel(null)
|
||||
await activeContext.release().catch(() => {})
|
||||
}
|
||||
|
||||
await FileSystem.deleteAsync(modelPath(modelID), { idempotent: true }).catch(() => {})
|
||||
const nextInstalled = await refreshInstalledWhisperModels()
|
||||
|
||||
if (defaultWhisperModel === modelID) {
|
||||
const fallbackModel = nextInstalled[0] ?? DEFAULT_WHISPER_MODEL
|
||||
setDefaultWhisperModel(fallbackModel)
|
||||
try {
|
||||
await ensureWhisperModelReady(fallbackModel)
|
||||
} catch {
|
||||
// Keep UI responsive if fallback init fails.
|
||||
}
|
||||
} else if (activeWhisperModel == null && nextInstalled.includes(defaultWhisperModel)) {
|
||||
try {
|
||||
await ensureWhisperModelReady(defaultWhisperModel)
|
||||
} catch {
|
||||
// Keep UI responsive if default model init fails.
|
||||
}
|
||||
}
|
||||
|
||||
Haptics.selectionAsync().catch(() => {})
|
||||
},
|
||||
[
|
||||
activeWhisperModel,
|
||||
defaultWhisperModel,
|
||||
downloadingModelID,
|
||||
ensureWhisperModelReady,
|
||||
modelPath,
|
||||
refreshInstalledWhisperModels,
|
||||
stopRecording,
|
||||
],
|
||||
)
|
||||
|
||||
const completeSend = useCallback(() => {
|
||||
if (sendSettleTimeoutRef.current) {
|
||||
@@ -960,10 +1526,13 @@ export default function DictationScreen() {
|
||||
}
|
||||
}, [stopRecording])
|
||||
|
||||
const modelLoading = !model.isReady
|
||||
const prog = model.downloadProgress > 1 ? model.downloadProgress / 100 : model.downloadProgress
|
||||
const load = Math.max(0, Math.min(1, Number.isFinite(prog) ? prog : 0))
|
||||
const pct = Math.round(load * 100)
|
||||
const modelDownloading = downloadingModelID !== null
|
||||
const modelLoading = isPreparingWhisperModel || activeWhisperModel == null || modelDownloading || isTranscribingBulk
|
||||
const modelLoadingState = modelDownloading ? "downloading" : modelLoading ? "loading" : "ready"
|
||||
const pct = Math.round(Math.max(0, Math.min(1, downloadProgress)) * 100)
|
||||
const loadingModelLabel = downloadingModelID
|
||||
? WHISPER_MODEL_LABELS[downloadingModelID]
|
||||
: WHISPER_MODEL_LABELS[defaultWhisperModel]
|
||||
const hasTranscript = transcribedText.trim().length > 0
|
||||
const shouldShowSend = hasCompletedSession && hasTranscript
|
||||
const activeServer = servers.find((s) => s.id === activeServerId) ?? null
|
||||
@@ -995,12 +1564,12 @@ export default function DictationScreen() {
|
||||
}, [isRecording, recordingProgress])
|
||||
|
||||
useEffect(() => {
|
||||
const isGenerating = isRecording || model.isGenerating
|
||||
const isGenerating = isRecording
|
||||
waveformVisibility.value = withTiming(isGenerating ? 1 : 0, {
|
||||
duration: isGenerating ? 180 : 240,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
})
|
||||
}, [isRecording, model.isGenerating, waveformVisibility])
|
||||
}, [isRecording, waveformVisibility])
|
||||
|
||||
useEffect(() => {
|
||||
serverMenuProgress.value = withTiming(isDropdownOpen ? 1 : 0, {
|
||||
@@ -1736,14 +2305,16 @@ export default function DictationScreen() {
|
||||
<View style={styles.transcriptionArea}>
|
||||
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
void handleDeleteModel()
|
||||
}}
|
||||
onPress={handleOpenWhisperSettings}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
disabled={modelLoading || modelReset}
|
||||
>
|
||||
<Text style={styles.modelDeleteIcon}>DL</Text>
|
||||
<SymbolView
|
||||
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
|
||||
size={18}
|
||||
weight="semibold"
|
||||
tintColor="#B8BDC9"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleClearTranscript}
|
||||
@@ -1760,6 +2331,12 @@ export default function DictationScreen() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{whisperError ? (
|
||||
<View style={styles.modelErrorBadge}>
|
||||
<Text style={styles.modelErrorText}>{whisperError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.transcriptionScroll}
|
||||
@@ -1799,11 +2376,25 @@ export default function DictationScreen() {
|
||||
style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]}
|
||||
>
|
||||
<View style={styles.recordButton}>
|
||||
{modelLoading ? (
|
||||
{isTranscribingBulk ? (
|
||||
<View style={styles.recordBusyCenter}>
|
||||
<ActivityIndicator color="#FF2E3F" size="small" />
|
||||
</View>
|
||||
) : modelLoadingState !== "ready" ? (
|
||||
<>
|
||||
<View style={[styles.loadFill, { width: `${Math.max(pct, 3)}%` }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.loadFill,
|
||||
modelLoadingState === "loading" && styles.loadFillPending,
|
||||
{ width: modelLoadingState === "downloading" ? `${Math.max(pct, 3)}%` : "100%" },
|
||||
]}
|
||||
/>
|
||||
<View style={styles.loadOverlay} pointerEvents="none">
|
||||
<Text style={styles.loadText}>{`Downloading model ${pct}%`}</Text>
|
||||
<Text style={styles.loadText}>
|
||||
{modelLoadingState === "downloading"
|
||||
? `Downloading ${loadingModelLabel} ${pct}%`
|
||||
: `Loading ${loadingModelLabel}`}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
@@ -1831,6 +2422,162 @@ export default function DictationScreen() {
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={whisperSettingsOpen}
|
||||
animationType="slide"
|
||||
presentationStyle="formSheet"
|
||||
onRequestClose={() => setWhisperSettingsOpen(false)}
|
||||
>
|
||||
<SafeAreaView style={styles.settingsRoot}>
|
||||
<View style={styles.settingsTop}>
|
||||
<View style={styles.settingsTitleBlock}>
|
||||
<Text style={styles.settingsTitle}>Whisper models</Text>
|
||||
<Text style={styles.settingsSubtitle}>Default: {WHISPER_MODEL_LABELS[defaultWhisperModel]}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => setWhisperSettingsOpen(false)}>
|
||||
<Text style={styles.settingsClose}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingsModeRow}>
|
||||
<Text style={styles.settingsModeLabel}>Transcription</Text>
|
||||
<View style={styles.settingsModeControls}>
|
||||
<Pressable
|
||||
onPress={() => setTranscriptionMode("bulk")}
|
||||
disabled={isRecording || isTranscribingBulk}
|
||||
style={({ pressed }) => [
|
||||
styles.settingsModeButton,
|
||||
transcriptionMode === "bulk" && styles.settingsModeButtonActive,
|
||||
(isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled,
|
||||
pressed && styles.clearButtonPressed,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingsModeButtonText,
|
||||
transcriptionMode === "bulk" && styles.settingsModeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
On Release
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setTranscriptionMode("realtime")}
|
||||
disabled={isRecording || isTranscribingBulk}
|
||||
style={({ pressed }) => [
|
||||
styles.settingsModeButton,
|
||||
transcriptionMode === "realtime" && styles.settingsModeButtonActive,
|
||||
(isRecording || isTranscribingBulk) && styles.settingsInlinePressableDisabled,
|
||||
pressed && styles.clearButtonPressed,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingsModeButtonText,
|
||||
transcriptionMode === "realtime" && styles.settingsModeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Realtime
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.settingsScroll} contentContainerStyle={styles.settingsContent}>
|
||||
{WHISPER_MODELS.map((modelID) => {
|
||||
const installed = installedWhisperModels.includes(modelID)
|
||||
const isDefault = defaultWhisperModel === modelID
|
||||
const isDownloading = downloadingModelID === modelID
|
||||
const actionDisabled = (downloadingModelID !== null && !isDownloading) || isTranscribingBulk
|
||||
const rowLabel = isDefault ? `${modelID} · default` : modelID
|
||||
const actionIcon = isDownloading ? "…" : installed ? "✓" : "↓"
|
||||
const downloadPct = Math.round(Math.max(0, Math.min(1, downloadProgress)) * 100)
|
||||
const actionLabel = isDownloading
|
||||
? "Downloading"
|
||||
: installed
|
||||
? isDefault
|
||||
? "Selected"
|
||||
: "Select"
|
||||
: "Download"
|
||||
const sizeLabel = formatWhisperModelSize(WHISPER_MODEL_SIZES[modelID])
|
||||
|
||||
return (
|
||||
<View key={modelID} style={styles.settingsInlineRow}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (installed) {
|
||||
void handleSelectWhisperModel(modelID)
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
if (!installed || isDownloading) return
|
||||
Alert.alert("Delete model?", `Remove ${modelID} from this device?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void handleDeleteWhisperModel(modelID)
|
||||
},
|
||||
},
|
||||
])
|
||||
}}
|
||||
delayLongPress={350}
|
||||
disabled={!installed || actionDisabled || isPreparingWhisperModel}
|
||||
style={({ pressed }) => [
|
||||
styles.settingsInlineLabelPressable,
|
||||
(!installed || actionDisabled || isPreparingWhisperModel) &&
|
||||
styles.settingsInlinePressableDisabled,
|
||||
pressed && styles.clearButtonPressed,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.settingsInlineName}>{rowLabel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.settingsInlineSize}>{sizeLabel}</Text>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (isDownloading) return
|
||||
if (installed) {
|
||||
void handleSelectWhisperModel(modelID)
|
||||
return
|
||||
}
|
||||
void handleDownloadWhisperModel(modelID)
|
||||
}}
|
||||
disabled={actionDisabled || (installed && isPreparingWhisperModel)}
|
||||
accessibilityLabel={actionLabel}
|
||||
style={({ pressed }) => [
|
||||
styles.settingsInlineIconButton,
|
||||
(actionDisabled || (installed && isPreparingWhisperModel)) &&
|
||||
styles.settingsInlinePressableDisabled,
|
||||
pressed && styles.clearButtonPressed,
|
||||
]}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<View style={styles.settingsDownloadCircle}>
|
||||
<Text style={styles.settingsDownloadCircleText}>{downloadPct}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.settingsInlineIcon,
|
||||
installed && styles.settingsInlineIconInstalled,
|
||||
isDownloading && styles.settingsInlineIconDownloading,
|
||||
]}
|
||||
>
|
||||
{actionIcon}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={scanOpen}
|
||||
animationType="slide"
|
||||
@@ -2102,11 +2849,23 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
modelDeleteIcon: {
|
||||
color: "#8FB4FF",
|
||||
fontSize: 14,
|
||||
fontWeight: "800",
|
||||
letterSpacing: 0.6,
|
||||
modelErrorBadge: {
|
||||
alignSelf: "flex-start",
|
||||
marginLeft: 14,
|
||||
marginTop: 8,
|
||||
marginBottom: 2,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#3A1A1D",
|
||||
borderWidth: 1,
|
||||
borderColor: "#5D292F",
|
||||
},
|
||||
modelErrorText: {
|
||||
color: "#FFB9BF",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
monitorBadge: {
|
||||
alignSelf: "flex-start",
|
||||
@@ -2177,6 +2936,12 @@ const styles = StyleSheet.create({
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
},
|
||||
recordBusyCenter: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
loadFill: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
@@ -2184,6 +2949,9 @@ const styles = StyleSheet.create({
|
||||
bottom: 0,
|
||||
backgroundColor: "#FF5B47",
|
||||
},
|
||||
loadFillPending: {
|
||||
backgroundColor: "#66423C",
|
||||
},
|
||||
loadOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
@@ -2196,6 +2964,152 @@ const styles = StyleSheet.create({
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
settingsRoot: {
|
||||
flex: 1,
|
||||
backgroundColor: "#121212",
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
gap: 12,
|
||||
},
|
||||
settingsTop: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
},
|
||||
settingsTitleBlock: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
settingsTitle: {
|
||||
color: "#F1F1F1",
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
},
|
||||
settingsSubtitle: {
|
||||
color: "#999999",
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
},
|
||||
settingsClose: {
|
||||
color: "#C5C5C5",
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
},
|
||||
settingsModeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#2B2B2B",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: "#171717",
|
||||
},
|
||||
settingsModeLabel: {
|
||||
color: "#D2D2D2",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
settingsModeControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
settingsModeButton: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#3A3A3A",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#1E1E1E",
|
||||
},
|
||||
settingsModeButtonActive: {
|
||||
borderColor: "#6B3A31",
|
||||
backgroundColor: "#3D231E",
|
||||
},
|
||||
settingsModeButtonText: {
|
||||
color: "#B9B9B9",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
settingsModeButtonTextActive: {
|
||||
color: "#FFD8D2",
|
||||
},
|
||||
settingsScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
settingsContent: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
settingsInlineRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: 44,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#242424",
|
||||
},
|
||||
settingsInlineLabelPressable: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: 10,
|
||||
paddingRight: 12,
|
||||
},
|
||||
settingsInlinePressableDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
settingsInlineName: {
|
||||
color: "#E7E7E7",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
settingsInlineSize: {
|
||||
color: "#8F8F8F",
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
minWidth: 64,
|
||||
textAlign: "right",
|
||||
},
|
||||
settingsInlineIconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
marginLeft: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
settingsInlineIcon: {
|
||||
color: "#D0D0D0",
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
lineHeight: 19,
|
||||
},
|
||||
settingsInlineIconInstalled: {
|
||||
color: "#E2B1A8",
|
||||
},
|
||||
settingsInlineIconDownloading: {
|
||||
color: "#FFD7CE",
|
||||
fontWeight: "700",
|
||||
},
|
||||
settingsDownloadCircle: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: "#FF6A57",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#2A1715",
|
||||
},
|
||||
settingsDownloadCircleText: {
|
||||
color: "#FFD9D2",
|
||||
fontSize: 9,
|
||||
fontWeight: "700",
|
||||
letterSpacing: -0.1,
|
||||
lineHeight: 10,
|
||||
},
|
||||
scanRoot: {
|
||||
flex: 1,
|
||||
backgroundColor: "#101014",
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
declare module "whisper.rn" {
|
||||
export type TranscribeOptions = {
|
||||
language?: string
|
||||
translate?: boolean
|
||||
maxLen?: number
|
||||
prompt?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type TranscribeResult = {
|
||||
result: string
|
||||
language: string
|
||||
segments: {
|
||||
text: string
|
||||
t0: number
|
||||
t1: number
|
||||
}[]
|
||||
isAborted?: boolean
|
||||
}
|
||||
|
||||
export type TranscribeRealtimeEvent = {
|
||||
contextId: number
|
||||
jobId: number
|
||||
isCapturing: boolean
|
||||
isStoppedByAction?: boolean
|
||||
code: number
|
||||
data?: TranscribeResult
|
||||
error?: string
|
||||
processTime: number
|
||||
recordingTime: number
|
||||
}
|
||||
|
||||
export type TranscribeRealtimeOptions = TranscribeOptions & {
|
||||
realtimeAudioSec?: number
|
||||
realtimeAudioSliceSec?: number
|
||||
realtimeAudioMinSec?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type WhisperContext = {
|
||||
id: number
|
||||
gpu: boolean
|
||||
reasonNoGPU: string
|
||||
transcribeRealtime(options?: TranscribeRealtimeOptions): Promise<{
|
||||
stop: () => Promise<void>
|
||||
subscribe: (callback: (event: TranscribeRealtimeEvent) => void) => void
|
||||
}>
|
||||
transcribeData(
|
||||
data: ArrayBuffer,
|
||||
options?: TranscribeOptions,
|
||||
): {
|
||||
stop: () => Promise<void>
|
||||
promise: Promise<TranscribeResult>
|
||||
}
|
||||
release(): Promise<void>
|
||||
}
|
||||
|
||||
export type ContextOptions = {
|
||||
filePath: string | number
|
||||
useGpu?: boolean
|
||||
useCoreMLIos?: boolean
|
||||
useFlashAttn?: boolean
|
||||
}
|
||||
|
||||
export function initWhisper(options: ContextOptions): Promise<WhisperContext>
|
||||
export function releaseAllWhisper(): Promise<void>
|
||||
}
|
||||
|
||||
declare module "whisper.rn/realtime-transcription/index" {
|
||||
import type { TranscribeOptions, TranscribeResult, WhisperContext } from "whisper.rn"
|
||||
|
||||
export type RealtimeTranscribeEvent = {
|
||||
type: "start" | "transcribe" | "end" | "error"
|
||||
sliceIndex: number
|
||||
data?: TranscribeResult
|
||||
isCapturing: boolean
|
||||
processTime: number
|
||||
recordingTime: number
|
||||
}
|
||||
|
||||
export type RealtimeOptions = {
|
||||
audioSliceSec?: number
|
||||
audioMinSec?: number
|
||||
maxSlicesInMemory?: number
|
||||
transcribeOptions?: TranscribeOptions
|
||||
logger?: (message: string) => void
|
||||
}
|
||||
|
||||
export type RealtimeTranscriberCallbacks = {
|
||||
onTranscribe?: (event: RealtimeTranscribeEvent) => void
|
||||
onError?: (error: string) => void
|
||||
onStatusChange?: (isActive: boolean) => void
|
||||
}
|
||||
|
||||
export type RealtimeTranscriberDependencies = {
|
||||
whisperContext: WhisperContext
|
||||
audioStream: unknown
|
||||
vadContext?: unknown
|
||||
fs?: unknown
|
||||
}
|
||||
|
||||
export class RealtimeTranscriber {
|
||||
constructor(
|
||||
dependencies: RealtimeTranscriberDependencies,
|
||||
options?: RealtimeOptions,
|
||||
callbacks?: RealtimeTranscriberCallbacks,
|
||||
)
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
release(): Promise<void>
|
||||
updateCallbacks(callbacks: Partial<RealtimeTranscriberCallbacks>): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module "whisper.rn/realtime-transcription" {
|
||||
export * from "whisper.rn/realtime-transcription/index"
|
||||
}
|
||||
|
||||
declare module "whisper.rn/src/realtime-transcription" {
|
||||
export * from "whisper.rn/realtime-transcription/index"
|
||||
}
|
||||
|
||||
declare module "whisper.rn/realtime-transcription/adapters/AudioPcmStreamAdapter" {
|
||||
export class AudioPcmStreamAdapter {
|
||||
initialize(config: Record<string, unknown>): Promise<void>
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
isRecording(): boolean
|
||||
onData(callback: (data: unknown) => void): void
|
||||
onError(callback: (error: string) => void): void
|
||||
onStatusChange(callback: (isRecording: boolean) => void): void
|
||||
release(): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
declare module "whisper.rn/src/realtime-transcription/adapters/AudioPcmStreamAdapter" {
|
||||
export * from "whisper.rn/realtime-transcription/adapters/AudioPcmStreamAdapter"
|
||||
}
|
||||
@@ -139,8 +139,8 @@ async function notify(input: { type: Type; sessionID: string }): Promise<Notify>
|
||||
const session = await Session.get(sessionID)
|
||||
out.title = session.title
|
||||
|
||||
let latestUser: string | undefined
|
||||
for await (const msg of MessageV2.stream(sessionID)) {
|
||||
if (msg.info.role !== "user") continue
|
||||
const body = msg.parts
|
||||
.map((part) => {
|
||||
if (part.type !== "text") return ""
|
||||
@@ -151,8 +151,19 @@ async function notify(input: { type: Type; sessionID: string }): Promise<Notify>
|
||||
.join(" ")
|
||||
const next = words(body)
|
||||
if (!next) continue
|
||||
out.body = next
|
||||
break
|
||||
|
||||
if (msg.info.role === "assistant") {
|
||||
out.body = next
|
||||
break
|
||||
}
|
||||
|
||||
if (!latestUser && msg.info.role === "user") {
|
||||
latestUser = next
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.body) {
|
||||
out.body = latestUser
|
||||
}
|
||||
} catch (error) {
|
||||
log.info("notification metadata unavailable", {
|
||||
|
||||
Reference in New Issue
Block a user