Compare commits
18
Commits
v0.1.0
..
feat_macos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02f3f0dcb | ||
|
|
77c987ec1e | ||
|
|
c33d87b06e | ||
|
|
6011b85abb | ||
|
|
6c0a6eb498 | ||
|
|
3400e2b465 | ||
|
|
136bf7cfa3 | ||
|
|
d63e8dd703 | ||
|
|
ca1437a4fa | ||
|
|
357edf9a2d | ||
|
|
8f021959ef | ||
|
|
e4cf707d0a | ||
|
|
066c7d758e | ||
|
|
2b89bbd959 | ||
|
|
15ab99c740 | ||
|
|
e3dd14cef6 | ||
|
|
00a4c9c25a | ||
|
|
789c8d4762 |
No files matched your search
@@ -0,0 +1,69 @@
|
|||||||
|
name: Android
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
runs-on: debian-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
# Gitea's act resolver cannot peel the annotated v4 tag alias.
|
||||||
|
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
|
||||||
|
- name: Lint and test
|
||||||
|
working-directory: android
|
||||||
|
run: ./gradlew --no-daemon lintDebug testDebugUnitTest
|
||||||
|
- name: Build signed release APK
|
||||||
|
working-directory: android
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ORG_GRADLE_PROJECT_transuiStorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ORG_GRADLE_PROJECT_transuiKeyAlias: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ORG_GRADLE_PROJECT_transuiKeyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||||
|
echo "Release signing secrets are not configured; skipping release APK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
keystore="$RUNNER_TEMP/transui-release.jks"
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore"
|
||||||
|
export ORG_GRADLE_PROJECT_transuiKeystore="$keystore"
|
||||||
|
./gradlew --no-daemon assembleRelease
|
||||||
|
- name: Prepare APK artifacts
|
||||||
|
working-directory: android
|
||||||
|
run: |
|
||||||
|
mkdir -p ../artifacts
|
||||||
|
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk ../artifacts/transui-android-release.apk
|
||||||
|
cd ../artifacts
|
||||||
|
sha256sum ./*.apk > SHA256SUMS-android
|
||||||
|
else
|
||||||
|
echo "No release APK to publish"
|
||||||
|
fi
|
||||||
|
- name: Attach APKs to the Gitea release
|
||||||
|
if: gitea.event_name == 'release'
|
||||||
|
env:
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||||
|
run: scripts/publish-release-assets.sh
|
||||||
|
- name: Upload APKs
|
||||||
|
if: always()
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: transui-android
|
||||||
|
path: artifacts/
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
/build/
|
/build/
|
||||||
/artifacts/
|
/artifacts/
|
||||||
|
/android/.gradle/
|
||||||
|
/android/build/
|
||||||
|
/android/local.properties
|
||||||
|
/android/app/build/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Transui design
|
||||||
|
|
||||||
|
- Linux is a GTK3 desktop recorder. It supports hosted transcription with an optional local fallback and can type at the cursor or copy to the clipboard.
|
||||||
|
- macOS is a SwiftUI menu bar recorder targeting macOS 15. It supports the same hosted-first, local-fallback behavior as Linux, a global recording hotkey, launch at login, cursor typing, and clipboard output. When hosted-first is off, use local only. Its compact floating recorder uses teal level bars for recording feedback.
|
||||||
|
- Android is remote-only. It provides an Android `RecognitionService`, a voice-only input method, and a floating recognition activity. Do not add a local llama.cpp path to Android.
|
||||||
|
- All clients call OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …<astext>` or `language …<asr_text>` prefix.
|
||||||
|
- Generate the macOS Xcode project from `macos/project.yml` with XcodeGen. Treat the spec as the source of truth and leave `macos/Transui.xcodeproj` untracked.
|
||||||
|
- The Android voice UI is an opaque, compact system surface. Teal is the recording signal. `PushToTalkButton` draws the Apps and Stop Circle glyphs with Canvas because the target device drops `ImageButton` image layers. Keep the glyphs drawn above the button background.
|
||||||
|
|
||||||
|
## llama.cpp local endpoint
|
||||||
|
|
||||||
|
The Linux and macOS local fallback uses this multipart request. `response_format=json`
|
||||||
|
is required; `prompt` is optional and may request transcription or translation.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s -X POST -F response_format=json http://localhost:8080/v1/audio/transcriptions \
|
||||||
|
-F file="@test.mp3" \
|
||||||
|
-F 'prompt=Transcribe the following speech segment in English, and translate them into chinese.'
|
||||||
|
```
|
||||||
|
|
||||||
|
llama.cpp may return an event envelope instead of a bare OpenAI response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"transcript.text.done","text":"这是个测试。你能听到什么?我希望这很好。","usage":{"type":"tokens","input_tokens":241,"output_tokens":516,"total_tokens":757,"input_tokens_details":{"cached_tokens":0}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `text` as the transcript. Accept `transcript.text.done`; do not require a
|
||||||
|
bare `{ "text": "…" }` response.
|
||||||
+12
-2
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(transui VERSION 0.1.0 LANGUAGES CXX)
|
project(transui VERSION 0.1.1 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -11,7 +11,7 @@ pkg_check_modules(GSTREAMER REQUIRED IMPORTED_TARGET gstreamer-1.0)
|
|||||||
pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1)
|
pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1)
|
||||||
pkg_check_modules(JSON_GLIB REQUIRED IMPORTED_TARGET json-glib-1.0)
|
pkg_check_modules(JSON_GLIB REQUIRED IMPORTED_TARGET json-glib-1.0)
|
||||||
|
|
||||||
add_executable(transui src/main.cpp)
|
add_executable(transui src/main.cpp src/transcript_cleaner.cpp)
|
||||||
target_link_libraries(transui PRIVATE
|
target_link_libraries(transui PRIVATE
|
||||||
PkgConfig::GTK3
|
PkgConfig::GTK3
|
||||||
PkgConfig::CURL
|
PkgConfig::CURL
|
||||||
@@ -23,6 +23,16 @@ target_link_libraries(transui PRIVATE
|
|||||||
install(TARGETS transui RUNTIME DESTINATION bin)
|
install(TARGETS transui RUNTIME DESTINATION bin)
|
||||||
install(FILES data/io.github.transui.Transui.desktop DESTINATION share/applications)
|
install(FILES data/io.github.transui.Transui.desktop DESTINATION share/applications)
|
||||||
|
|
||||||
|
include(CTest)
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
add_executable(transcript_cleaner_test
|
||||||
|
tests/transcript_cleaner_test.cpp
|
||||||
|
src/transcript_cleaner.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(transcript_cleaner_test PRIVATE src)
|
||||||
|
add_test(NAME transcript_cleaner COMMAND transcript_cleaner_test)
|
||||||
|
endif()
|
||||||
|
|
||||||
set(CPACK_GENERATOR "DEB")
|
set(CPACK_GENERATOR "DEB")
|
||||||
set(CPACK_PACKAGE_NAME "transui")
|
set(CPACK_PACKAGE_NAME "transui")
|
||||||
set(CPACK_PACKAGE_CONTACT "Transui contributors")
|
set(CPACK_PACKAGE_CONTACT "Transui contributors")
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Transui
|
# Transui
|
||||||
|
|
||||||
A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp
|
A compact transcription tool for Linux, macOS, and Android. Linux and macOS
|
||||||
`/v1/audio/transcriptions` endpoints.
|
support OpenAI-compatible hosted endpoints with an optional llama.cpp local
|
||||||
|
fallback. Android provides remote-only speech recognition and voice input.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -13,19 +14,38 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \
|
|||||||
libjson-glib-dev wtype xdotool
|
libjson-glib-dev wtype xdotool
|
||||||
```
|
```
|
||||||
|
|
||||||
`wtype` is used on Wayland and `xdotool` on X11. They are runtime tools, so
|
On macOS, install Xcode 16 and XcodeGen.
|
||||||
you only need the one matching your session. API keys are saved in the
|
|
||||||
freedesktop Secret Service (GNOME Keyring, KWallet Secret Service, and so on),
|
|
||||||
not in the settings file.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cmake -S . -B build
|
cmake -S . -B build
|
||||||
cmake --build build
|
cmake --build build
|
||||||
sudo cmake --install build
|
sudo cmake --install build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd macos
|
||||||
|
xcodegen generate
|
||||||
|
open Transui.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the `Transui` scheme in Xcode.
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
Android release APKs build in Gitea CI. To run local checks, configure the
|
||||||
|
Android SDK and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
./gradlew lintDebug lintRelease testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -33,15 +53,3 @@ transui settings # configure endpoints, prompt, and destination
|
|||||||
transui record # open the record window
|
transui record # open the record window
|
||||||
transui # same as record
|
transui # same as record
|
||||||
```
|
```
|
||||||
|
|
||||||
Bind `transui record` to a shortcut in your desktop environment. Transui does
|
|
||||||
not register a global shortcut itself. Press the same shortcut again to stop
|
|
||||||
and transcribe the current recording. Audio is recorded as 16 kHz mono MP3 at
|
|
||||||
32 kbps to keep uploads small.
|
|
||||||
|
|
||||||
The **Hosted first, local fallback** setting tries the Hosted endpoint, then
|
|
||||||
the Local endpoint when the first request fails. With it disabled, Transui uses
|
|
||||||
the Local endpoint only.
|
|
||||||
|
|
||||||
Both a normal response (`{"text":"..."}`) and llama.cpp event response
|
|
||||||
(`{"type":"transcript.text.done","text":"..."}`) are accepted.
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'io.github.transui.android'
|
||||||
|
compileSdk 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'io.github.transui.android'
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 35
|
||||||
|
versionCode 2
|
||||||
|
versionName '0.1.1'
|
||||||
|
|
||||||
|
testInstrumentationRunner 'android.test.InstrumentationTestRunner'
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (project.hasProperty('transuiKeystore')) {
|
||||||
|
release {
|
||||||
|
storeFile file(project.property('transuiKeystore'))
|
||||||
|
storePassword project.property('transuiStorePassword')
|
||||||
|
keyAlias project.property('transuiKeyAlias')
|
||||||
|
keyPassword project.property('transuiKeyPassword')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
if (project.hasProperty('transuiKeystore')) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError true
|
||||||
|
checkReleaseBuilds true
|
||||||
|
warningsAsErrors true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".SpeechRecognitionActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/RecognitionTheme">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".TransuiRecognitionService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/provider_name"
|
||||||
|
tools:ignore="ExportedService">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.speech"
|
||||||
|
android:resource="@xml/recognition_service" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".TransuiInputMethodService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/voice_input"
|
||||||
|
android:permission="android.permission.BIND_INPUT_METHOD">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.view.InputMethod" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.view.im"
|
||||||
|
android:resource="@xml/input_method" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.LinearGradient;
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.graphics.Shader;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public final class AmplitudeView extends View {
|
||||||
|
private static final int LEVEL_COUNT = 192;
|
||||||
|
|
||||||
|
private final float[] levels = new float[LEVEL_COUNT];
|
||||||
|
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint edgePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Path waveform = new Path();
|
||||||
|
private final LinearGradient fillGradient;
|
||||||
|
private final Matrix gradientMatrix = new Matrix();
|
||||||
|
private final float sampleSpacing;
|
||||||
|
private final float minimumHalfHeight;
|
||||||
|
private final int signalColor;
|
||||||
|
private float previousLevel;
|
||||||
|
|
||||||
|
public AmplitudeView(Context context, AttributeSet attributes) {
|
||||||
|
super(context, attributes);
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
sampleSpacing = 5 * density;
|
||||||
|
minimumHalfHeight = 1.5f * density;
|
||||||
|
signalColor = context.getColor(R.color.signal);
|
||||||
|
|
||||||
|
fillPaint.setStyle(Paint.Style.FILL);
|
||||||
|
int transparentSignal = signalColor & 0x00ffffff;
|
||||||
|
fillGradient = new LinearGradient(
|
||||||
|
0, 0, 0, 1,
|
||||||
|
new int[]{transparentSignal, signalColor, transparentSignal},
|
||||||
|
new float[]{0, 0.5f, 1}, Shader.TileMode.CLAMP);
|
||||||
|
fillPaint.setShader(fillGradient);
|
||||||
|
edgePaint.setColor(signalColor);
|
||||||
|
edgePaint.setAlpha(210);
|
||||||
|
edgePaint.setStyle(Paint.Style.STROKE);
|
||||||
|
edgePaint.setStrokeWidth(1.5f * density);
|
||||||
|
edgePaint.setStrokeJoin(Paint.Join.ROUND);
|
||||||
|
centerPaint.setColor(signalColor);
|
||||||
|
centerPaint.setAlpha(70);
|
||||||
|
centerPaint.setStrokeWidth(density);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAmplitude(float amplitude) {
|
||||||
|
float level = Math.max(0, Math.min(1, amplitude));
|
||||||
|
level = (float) Math.sqrt(level);
|
||||||
|
level = Math.max(level, previousLevel * 0.58f);
|
||||||
|
System.arraycopy(levels, 1, levels, 0, levels.length - 1);
|
||||||
|
levels[levels.length - 1] = level;
|
||||||
|
previousLevel = level;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
Arrays.fill(levels, 0);
|
||||||
|
previousLevel = 0;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
|
||||||
|
super.onSizeChanged(width, height, oldWidth, oldHeight);
|
||||||
|
float top = getPaddingTop();
|
||||||
|
float contentHeight = Math.max(1, height - getPaddingTop() - getPaddingBottom());
|
||||||
|
gradientMatrix.setScale(1, contentHeight);
|
||||||
|
gradientMatrix.postTranslate(0, top);
|
||||||
|
fillGradient.setLocalMatrix(gradientMatrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
super.onDraw(canvas);
|
||||||
|
float availableWidth = Math.max(0, getWidth() - getPaddingLeft() - getPaddingRight());
|
||||||
|
int visibleCount = Math.min(LEVEL_COUNT,
|
||||||
|
Math.max(2, (int) (availableWidth / sampleSpacing) + 1));
|
||||||
|
float step = availableWidth / (visibleCount - 1);
|
||||||
|
float startX = getPaddingLeft();
|
||||||
|
float centerY = getHeight() / 2f;
|
||||||
|
float maximumHalfHeight = Math.max(minimumHalfHeight,
|
||||||
|
(getHeight() - getPaddingTop() - getPaddingBottom()) / 2f);
|
||||||
|
int firstLevel = LEVEL_COUNT - visibleCount;
|
||||||
|
|
||||||
|
canvas.drawLine(startX, centerY, startX + availableWidth, centerY, centerPaint);
|
||||||
|
waveform.reset();
|
||||||
|
|
||||||
|
float previousX = startX;
|
||||||
|
float previousY = upperY(firstLevel, centerY, maximumHalfHeight);
|
||||||
|
waveform.moveTo(previousX, previousY);
|
||||||
|
for (int index = 1; index < visibleCount; index++) {
|
||||||
|
float x = startX + index * step;
|
||||||
|
float y = upperY(firstLevel + index, centerY, maximumHalfHeight);
|
||||||
|
waveform.quadTo(previousX, previousY, (previousX + x) / 2, (previousY + y) / 2);
|
||||||
|
previousX = x;
|
||||||
|
previousY = y;
|
||||||
|
}
|
||||||
|
waveform.lineTo(previousX, previousY);
|
||||||
|
|
||||||
|
previousY = lowerY(firstLevel + visibleCount - 1, centerY, maximumHalfHeight);
|
||||||
|
waveform.lineTo(previousX, previousY);
|
||||||
|
for (int index = visibleCount - 2; index >= 0; index--) {
|
||||||
|
float x = startX + index * step;
|
||||||
|
float y = lowerY(firstLevel + index, centerY, maximumHalfHeight);
|
||||||
|
waveform.quadTo(previousX, previousY, (previousX + x) / 2, (previousY + y) / 2);
|
||||||
|
previousX = x;
|
||||||
|
previousY = y;
|
||||||
|
}
|
||||||
|
waveform.lineTo(previousX, previousY);
|
||||||
|
waveform.close();
|
||||||
|
|
||||||
|
canvas.drawPath(waveform, fillPaint);
|
||||||
|
canvas.drawPath(waveform, edgePaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float upperY(int index, float centerY, float maximumHalfHeight) {
|
||||||
|
return centerY - minimumHalfHeight
|
||||||
|
- levels[index] * (maximumHalfHeight - minimumHalfHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float lowerY(int index, float centerY, float maximumHalfHeight) {
|
||||||
|
return centerY + minimumHalfHeight
|
||||||
|
+ levels[index] * (maximumHalfHeight - minimumHalfHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
final class AppConfig {
|
||||||
|
final String endpoint;
|
||||||
|
final String model;
|
||||||
|
final String apiKey;
|
||||||
|
final String prompt;
|
||||||
|
final boolean pushToTalk;
|
||||||
|
|
||||||
|
AppConfig(
|
||||||
|
String endpoint,
|
||||||
|
String model,
|
||||||
|
String apiKey,
|
||||||
|
String prompt,
|
||||||
|
boolean pushToTalk) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.model = model;
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.prompt = prompt;
|
||||||
|
this.pushToTalk = pushToTalk;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasUsableEndpoint() {
|
||||||
|
return !endpoint.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
final class AudioLevelMeter {
|
||||||
|
private double noiseFloorDecibels = Double.NaN;
|
||||||
|
|
||||||
|
float measure(short[] samples, int count) {
|
||||||
|
double squaredTotal = 0;
|
||||||
|
double peak = 0;
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
double normalized = samples[index] / (double) Short.MAX_VALUE;
|
||||||
|
squaredTotal += normalized * normalized;
|
||||||
|
peak = Math.max(peak, Math.abs(normalized));
|
||||||
|
}
|
||||||
|
double rootMeanSquare = Math.sqrt(squaredTotal / count);
|
||||||
|
double effectiveLevel = Math.max(rootMeanSquare, peak * 0.12);
|
||||||
|
if (effectiveLevel <= 0) return 0;
|
||||||
|
|
||||||
|
double decibels = 20 * Math.log10(effectiveLevel);
|
||||||
|
if (Double.isNaN(noiseFloorDecibels)) {
|
||||||
|
noiseFloorDecibels = decibels;
|
||||||
|
} else if (decibels < noiseFloorDecibels + 3) {
|
||||||
|
noiseFloorDecibels += (decibels - noiseFloorDecibels) * 0.05;
|
||||||
|
} else {
|
||||||
|
noiseFloorDecibels += (decibels - noiseFloorDecibels) * 0.001;
|
||||||
|
}
|
||||||
|
|
||||||
|
double relative = (decibels - noiseFloorDecibels - 1.5) / 22;
|
||||||
|
double calibratedFallback = ((decibels + 72) / 54) * 0.35;
|
||||||
|
return (float) Math.max(0, Math.min(1, Math.max(relative, calibratedFallback)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
|
||||||
|
final class ConfigRepository {
|
||||||
|
private static final String PREFS = "settings";
|
||||||
|
private final SharedPreferences preferences;
|
||||||
|
|
||||||
|
ConfigRepository(Context context) {
|
||||||
|
preferences = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
removeObsoleteLocalSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig load() {
|
||||||
|
return new AppConfig(
|
||||||
|
preferences.getString("remote_endpoint", ""),
|
||||||
|
preferences.getString("remote_model", ""),
|
||||||
|
decryptOrEmpty(preferences.getString("remote_key", "")),
|
||||||
|
preferences.getString("prompt", ""),
|
||||||
|
preferences.getBoolean("push_to_talk", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(AppConfig config) throws GeneralSecurityException, IOException {
|
||||||
|
preferences.edit()
|
||||||
|
.putString("remote_endpoint", config.endpoint)
|
||||||
|
.putString("remote_model", config.model)
|
||||||
|
.putString("remote_key", SecretStore.encrypt(config.apiKey))
|
||||||
|
.putString("prompt", config.prompt)
|
||||||
|
.putBoolean("push_to_talk", config.pushToTalk)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeObsoleteLocalSettings() {
|
||||||
|
if (!preferences.contains("local_endpoint")
|
||||||
|
&& !preferences.contains("local_model")
|
||||||
|
&& !preferences.contains("local_key")
|
||||||
|
&& !preferences.contains("remote_first")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preferences.edit()
|
||||||
|
.remove("local_endpoint")
|
||||||
|
.remove("local_model")
|
||||||
|
.remove("local_key")
|
||||||
|
.remove("remote_first")
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String decryptOrEmpty(String value) {
|
||||||
|
try {
|
||||||
|
return SecretStore.decrypt(value);
|
||||||
|
} catch (GeneralSecurityException | IOException | IllegalArgumentException ignored) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.ComponentName;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.Switch;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
|
||||||
|
public final class MainActivity extends Activity {
|
||||||
|
private static final int MICROPHONE_REQUEST = 1;
|
||||||
|
|
||||||
|
private static final String ACTION_LANGUAGE_SETTINGS = "android.settings.LANGUAGE_SETTINGS";
|
||||||
|
|
||||||
|
private EditText endpoint;
|
||||||
|
private EditText model;
|
||||||
|
private EditText apiKey;
|
||||||
|
private EditText prompt;
|
||||||
|
private Switch pushToTalk;
|
||||||
|
private TextView providerStatus;
|
||||||
|
private Button microphone;
|
||||||
|
private ConfigRepository repository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_main);
|
||||||
|
repository = new ConfigRepository(this);
|
||||||
|
bindViews();
|
||||||
|
showConfig(repository.load());
|
||||||
|
|
||||||
|
findViewById(R.id.save).setOnClickListener(view -> saveConfig());
|
||||||
|
microphone.setOnClickListener(view -> requestMicrophone());
|
||||||
|
findViewById(R.id.language_settings).setOnClickListener(view -> openLanguageSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, results);
|
||||||
|
if (requestCode == MICROPHONE_REQUEST) updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindViews() {
|
||||||
|
endpoint = findViewById(R.id.endpoint);
|
||||||
|
model = findViewById(R.id.model);
|
||||||
|
apiKey = findViewById(R.id.api_key);
|
||||||
|
prompt = findViewById(R.id.prompt);
|
||||||
|
pushToTalk = findViewById(R.id.push_to_talk);
|
||||||
|
providerStatus = findViewById(R.id.provider_status);
|
||||||
|
microphone = findViewById(R.id.microphone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showConfig(AppConfig config) {
|
||||||
|
endpoint.setText(config.endpoint);
|
||||||
|
model.setText(config.model);
|
||||||
|
apiKey.setText(config.apiKey);
|
||||||
|
prompt.setText(config.prompt);
|
||||||
|
pushToTalk.setChecked(config.pushToTalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean saveConfig() {
|
||||||
|
AppConfig config = new AppConfig(
|
||||||
|
value(endpoint).trim(),
|
||||||
|
value(model).trim(),
|
||||||
|
value(apiKey).trim(),
|
||||||
|
value(prompt),
|
||||||
|
pushToTalk.isChecked());
|
||||||
|
try {
|
||||||
|
repository.save(config);
|
||||||
|
Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
|
||||||
|
return true;
|
||||||
|
} catch (GeneralSecurityException | IOException error) {
|
||||||
|
Toast.makeText(this, R.string.settings_save_failed, Toast.LENGTH_LONG).show();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestMicrophone() {
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, MICROPHONE_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openLanguageSettings() {
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(ACTION_LANGUAGE_SETTINGS));
|
||||||
|
return;
|
||||||
|
} catch (ActivityNotFoundException ignored) {
|
||||||
|
// Older Android builds expose the combined Languages and input page here.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(Settings.ACTION_INPUT_METHOD_SETTINGS));
|
||||||
|
} catch (ActivityNotFoundException ignored) {
|
||||||
|
Toast.makeText(this, R.string.language_settings_unavailable, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStatus() {
|
||||||
|
ComponentName service = new ComponentName(this, TransuiRecognitionService.class);
|
||||||
|
String selected = Settings.Secure.getString(getContentResolver(), "voice_recognition_service");
|
||||||
|
ComponentName selectedService = selected == null ? null : ComponentName.unflattenFromString(selected);
|
||||||
|
providerStatus.setText(service.equals(selectedService)
|
||||||
|
? R.string.status_selected
|
||||||
|
: R.string.status_not_selected);
|
||||||
|
boolean allowed = checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
microphone.setText(allowed ? R.string.microphone_allowed : R.string.allow_microphone);
|
||||||
|
microphone.setEnabled(!allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String value(EditText field) {
|
||||||
|
return field.getText().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.widget.ImageButton;
|
||||||
|
|
||||||
|
public final class PushToTalkButton extends ImageButton {
|
||||||
|
enum IconMode {
|
||||||
|
APPS,
|
||||||
|
STOP_CIRCLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Paint iconPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private Runnable pressedAction;
|
||||||
|
private Runnable releasedAction;
|
||||||
|
private boolean trackingPress;
|
||||||
|
private IconMode iconMode = IconMode.STOP_CIRCLE;
|
||||||
|
|
||||||
|
public PushToTalkButton(Context context, AttributeSet attributes) {
|
||||||
|
super(context, attributes);
|
||||||
|
iconPaint.setColor(context.getColor(R.color.on_signal));
|
||||||
|
setImageDrawable(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIconMode(IconMode iconMode) {
|
||||||
|
this.iconMode = iconMode;
|
||||||
|
setImageDrawable(null);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
|
||||||
|
this.pressedAction = pressedAction;
|
||||||
|
this.releasedAction = releasedAction;
|
||||||
|
trackingPress = false;
|
||||||
|
setPressed(false);
|
||||||
|
setScaleX(1);
|
||||||
|
setScaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
if (pressedAction == null || releasedAction == null) return super.onTouchEvent(event);
|
||||||
|
if (event.getAction() == MotionEvent.ACTION_DOWN) {
|
||||||
|
trackingPress = true;
|
||||||
|
pressedAction.run();
|
||||||
|
setPressed(true);
|
||||||
|
animate().scaleX(0.92f).scaleY(0.92f).setDuration(90).start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||||
|
if (trackingPress) releasedAction.run();
|
||||||
|
trackingPress = false;
|
||||||
|
setPressed(false);
|
||||||
|
animate().scaleX(1).scaleY(1).setDuration(120).start();
|
||||||
|
super.performClick();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.getAction() == MotionEvent.ACTION_CANCEL) {
|
||||||
|
if (trackingPress) releasedAction.run();
|
||||||
|
trackingPress = false;
|
||||||
|
setPressed(false);
|
||||||
|
animate().scaleX(1).scaleY(1).setDuration(120).start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performClick() {
|
||||||
|
return super.performClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
super.onDraw(canvas);
|
||||||
|
float contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
|
||||||
|
float contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
|
||||||
|
float size = Math.min(contentWidth, contentHeight);
|
||||||
|
float centerX = getWidth() / 2f;
|
||||||
|
float centerY = getHeight() / 2f;
|
||||||
|
if (iconMode == IconMode.APPS) {
|
||||||
|
drawAppsIcon(canvas, centerX, centerY, size);
|
||||||
|
} else {
|
||||||
|
drawStopCircleIcon(canvas, centerX, centerY, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawAppsIcon(Canvas canvas, float centerX, float centerY, float size) {
|
||||||
|
iconPaint.setStyle(Paint.Style.FILL);
|
||||||
|
float spacing = size * 0.27f;
|
||||||
|
float radius = size * 0.09f;
|
||||||
|
for (int row = -1; row <= 1; row++) {
|
||||||
|
for (int column = -1; column <= 1; column++) {
|
||||||
|
canvas.drawCircle(
|
||||||
|
centerX + column * spacing,
|
||||||
|
centerY + row * spacing,
|
||||||
|
radius,
|
||||||
|
iconPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawStopCircleIcon(Canvas canvas, float centerX, float centerY, float size) {
|
||||||
|
iconPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
iconPaint.setStrokeWidth(size * 0.12f);
|
||||||
|
canvas.drawCircle(centerX, centerY, size * 0.42f, iconPaint);
|
||||||
|
iconPaint.setStyle(Paint.Style.FILL);
|
||||||
|
float squareHalfSize = size * 0.17f;
|
||||||
|
canvas.drawRect(
|
||||||
|
centerX - squareHalfSize,
|
||||||
|
centerY - squareHalfSize,
|
||||||
|
centerX + squareHalfSize,
|
||||||
|
centerY + squareHalfSize,
|
||||||
|
iconPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec;
|
||||||
|
import android.security.keystore.KeyProperties;
|
||||||
|
import android.util.Base64;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.KeyGenerator;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
|
||||||
|
final class SecretStore {
|
||||||
|
private static final String KEY_ALIAS = "transui_api_keys";
|
||||||
|
private static final String KEYSTORE = "AndroidKeyStore";
|
||||||
|
|
||||||
|
private SecretStore() {}
|
||||||
|
|
||||||
|
static String encrypt(String value) throws GeneralSecurityException, IOException {
|
||||||
|
if (value.isEmpty()) return "";
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey());
|
||||||
|
byte[] ciphertext = cipher.doFinal(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
return Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)
|
||||||
|
+ "."
|
||||||
|
+ Base64.encodeToString(ciphertext, Base64.NO_WRAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String decrypt(String value) throws GeneralSecurityException, IOException {
|
||||||
|
if (value == null || value.isEmpty()) return "";
|
||||||
|
String[] parts = value.split("\\.", 2);
|
||||||
|
if (parts.length != 2) throw new GeneralSecurityException("Invalid encrypted value");
|
||||||
|
byte[] iv = Base64.decode(parts[0], Base64.NO_WRAP);
|
||||||
|
byte[] ciphertext = Base64.decode(parts[1], Base64.NO_WRAP);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), new GCMParameterSpec(128, iv));
|
||||||
|
byte[] plaintext = cipher.doFinal(ciphertext);
|
||||||
|
String result = new String(plaintext, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
Arrays.fill(plaintext, (byte) 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SecretKey getOrCreateKey() throws GeneralSecurityException, IOException {
|
||||||
|
KeyStore keyStore = KeyStore.getInstance(KEYSTORE);
|
||||||
|
keyStore.load(null);
|
||||||
|
java.security.Key key = keyStore.getKey(KEY_ALIAS, null);
|
||||||
|
if (key instanceof SecretKey) return (SecretKey) key;
|
||||||
|
|
||||||
|
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE);
|
||||||
|
generator.init(new KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.build());
|
||||||
|
return generator.generateKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.speech.RecognizerIntent;
|
||||||
|
import android.speech.SpeechRecognizer;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public final class SpeechRecognitionActivity extends Activity {
|
||||||
|
private static final int MICROPHONE_REQUEST = 2;
|
||||||
|
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private final Runnable updateElapsed = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
long seconds = (SystemClock.elapsedRealtime() - recordingStartedAt) / 1000;
|
||||||
|
elapsed.setText(String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60));
|
||||||
|
handler.postDelayed(this, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private TextView status;
|
||||||
|
private TextView elapsed;
|
||||||
|
private AmplitudeView amplitude;
|
||||||
|
private PushToTalkButton record;
|
||||||
|
private AppConfig config;
|
||||||
|
private VoiceRecordingController controller;
|
||||||
|
private long recordingStartedAt;
|
||||||
|
private boolean sessionActive;
|
||||||
|
private boolean completed;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setResult(RESULT_CANCELED);
|
||||||
|
setContentView(R.layout.recognition_panel);
|
||||||
|
status = findViewById(R.id.recognition_status);
|
||||||
|
elapsed = findViewById(R.id.recognition_elapsed);
|
||||||
|
amplitude = findViewById(R.id.recognition_amplitude);
|
||||||
|
record = findViewById(R.id.recognition_record);
|
||||||
|
findViewById(R.id.recognition_cancel).setOnClickListener(view -> cancelAndFinish());
|
||||||
|
|
||||||
|
config = new ConfigRepository(this).load();
|
||||||
|
if (!config.hasUsableEndpoint()) {
|
||||||
|
showUnavailable(R.string.configure_first);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configureRecordButton();
|
||||||
|
ensurePermissionAndStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, results);
|
||||||
|
if (requestCode != MICROPHONE_REQUEST) return;
|
||||||
|
if (results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
prepareForInput();
|
||||||
|
} else {
|
||||||
|
showUnavailable(R.string.recording_failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
handler.removeCallbacks(updateElapsed);
|
||||||
|
if (!completed && controller != null) controller.cancel();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureRecordButton() {
|
||||||
|
record.setAlpha(1);
|
||||||
|
if (config.pushToTalk) {
|
||||||
|
record.setBackgroundResource(R.drawable.voice_pill_button);
|
||||||
|
resizeRecordButton(132, 64, 16);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.APPS);
|
||||||
|
record.setContentDescription(getString(R.string.hold_to_talk));
|
||||||
|
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
||||||
|
} else {
|
||||||
|
record.setBackgroundResource(R.drawable.voice_primary_button);
|
||||||
|
resizeRecordButton(64, 64, 19);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
|
record.setContentDescription(getString(R.string.stop));
|
||||||
|
record.setOnClickListener(view -> stopRecording());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
int width = Math.round(widthDp * density);
|
||||||
|
int height = Math.round(heightDp * density);
|
||||||
|
int padding = Math.round(paddingDp * density);
|
||||||
|
ViewGroup.LayoutParams parameters = record.getLayoutParams();
|
||||||
|
parameters.width = width;
|
||||||
|
parameters.height = height;
|
||||||
|
record.setLayoutParams(parameters);
|
||||||
|
record.setPadding(padding, padding, padding, padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensurePermissionAndStart() {
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
prepareForInput();
|
||||||
|
} else {
|
||||||
|
requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, MICROPHONE_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareForInput() {
|
||||||
|
if (config.pushToTalk) {
|
||||||
|
status.setText(R.string.hold_to_talk);
|
||||||
|
} else {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startRecording() {
|
||||||
|
if (sessionActive || completed) return;
|
||||||
|
sessionActive = true;
|
||||||
|
controller = new VoiceRecordingController(this, config, new UiListener());
|
||||||
|
controller.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopRecording() {
|
||||||
|
if (!sessionActive || controller == null) return;
|
||||||
|
record.setEnabled(false);
|
||||||
|
record.setAlpha(0.4f);
|
||||||
|
amplitude.reset();
|
||||||
|
controller.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showUnavailable(int message) {
|
||||||
|
status.setText(message);
|
||||||
|
elapsed.setText("");
|
||||||
|
amplitude.reset();
|
||||||
|
record.setEnabled(false);
|
||||||
|
record.setAlpha(0.4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelAndFinish() {
|
||||||
|
completed = true;
|
||||||
|
if (controller != null) controller.cancel();
|
||||||
|
handler.removeCallbacks(updateElapsed);
|
||||||
|
setResult(RESULT_CANCELED);
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishWithResult(String text) {
|
||||||
|
completed = true;
|
||||||
|
handler.removeCallbacks(updateElapsed);
|
||||||
|
ArrayList<String> results = new ArrayList<>();
|
||||||
|
results.add(text);
|
||||||
|
Intent result = new Intent();
|
||||||
|
result.putStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS, results);
|
||||||
|
result.putExtra(RecognizerIntent.EXTRA_CONFIDENCE_SCORES, new float[]{-1.0f});
|
||||||
|
setResult(RESULT_OK, result);
|
||||||
|
sendPendingResult(result, results);
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private void sendPendingResult(Intent result, ArrayList<String> results) {
|
||||||
|
PendingIntent pendingIntent;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
pendingIntent = getIntent().getParcelableExtra(
|
||||||
|
RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, PendingIntent.class);
|
||||||
|
} else {
|
||||||
|
pendingIntent = getIntent().getParcelableExtra(
|
||||||
|
RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT);
|
||||||
|
}
|
||||||
|
if (pendingIntent == null) return;
|
||||||
|
|
||||||
|
Bundle supplied = getIntent().getBundleExtra(
|
||||||
|
RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE);
|
||||||
|
if (supplied != null) {
|
||||||
|
supplied.putStringArrayList(RecognizerIntent.EXTRA_RESULTS, results);
|
||||||
|
supplied.putFloatArray(RecognizerIntent.EXTRA_CONFIDENCE_SCORES, new float[]{-1.0f});
|
||||||
|
result.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, supplied);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pendingIntent.send(this, RESULT_OK, result);
|
||||||
|
} catch (PendingIntent.CanceledException ignored) {
|
||||||
|
// The requesting application has already gone away.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class UiListener implements VoiceRecordingController.Listener {
|
||||||
|
@Override
|
||||||
|
public void onRecording() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
status.setText(R.string.listening);
|
||||||
|
if (!config.pushToTalk) {
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
|
}
|
||||||
|
recordingStartedAt = SystemClock.elapsedRealtime();
|
||||||
|
elapsed.setText(R.string.elapsed_zero);
|
||||||
|
handler.post(updateElapsed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAmplitude(float level) {
|
||||||
|
runOnUiThread(() -> amplitude.setAmplitude(level));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTranscribing() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
handler.removeCallbacks(updateElapsed);
|
||||||
|
status.setText(R.string.transcribing);
|
||||||
|
amplitude.reset();
|
||||||
|
record.setEnabled(false);
|
||||||
|
record.setAlpha(0.4f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResult(String text) {
|
||||||
|
runOnUiThread(() -> finishWithResult(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(int errorCode) {
|
||||||
|
runOnUiThread(() -> showUnavailable(
|
||||||
|
errorCode == SpeechRecognizer.ERROR_NETWORK
|
||||||
|
|| errorCode == SpeechRecognizer.ERROR_SERVER
|
||||||
|
? R.string.transcription_failed
|
||||||
|
: R.string.recording_failed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
final class TranscriptCleaner {
|
||||||
|
private static final String QWEN_LANGUAGE_PREFIX = "language ";
|
||||||
|
private static final String[] QWEN_TEXT_MARKERS = {"<asr_text>", "<astext>"};
|
||||||
|
|
||||||
|
private TranscriptCleaner() {}
|
||||||
|
|
||||||
|
static String clean(String transcript) {
|
||||||
|
// Qwen3-ASR prefixes raw transcripts with the detected language and a text marker.
|
||||||
|
int prefix = 0;
|
||||||
|
if (!transcript.isEmpty() && transcript.charAt(0) == '\ufeff') prefix++;
|
||||||
|
while (prefix < transcript.length() && Character.isWhitespace(transcript.charAt(prefix))) {
|
||||||
|
prefix++;
|
||||||
|
}
|
||||||
|
if (!transcript.startsWith(QWEN_LANGUAGE_PREFIX, prefix)) return transcript;
|
||||||
|
|
||||||
|
int marker = -1;
|
||||||
|
String matchedMarker = null;
|
||||||
|
for (String candidate : QWEN_TEXT_MARKERS) {
|
||||||
|
int position = transcript.indexOf(candidate, prefix + QWEN_LANGUAGE_PREFIX.length());
|
||||||
|
if (position >= 0 && (marker < 0 || position < marker)) {
|
||||||
|
marker = position;
|
||||||
|
matchedMarker = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchedMarker == null) return transcript;
|
||||||
|
return transcript.substring(marker + matchedMarker.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.speech.SpeechRecognizer;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
final class TranscriptionClient {
|
||||||
|
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||||
|
private volatile HttpURLConnection activeConnection;
|
||||||
|
|
||||||
|
Result transcribe(AppConfig config, byte[] wav) {
|
||||||
|
if (config.endpoint.isEmpty()) {
|
||||||
|
return Result.error("No transcription endpoint is configured", SpeechRecognizer.ERROR_CLIENT);
|
||||||
|
}
|
||||||
|
return request(config.endpoint, config.model, config.apiKey, config.prompt, wav);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
HttpURLConnection connection = activeConnection;
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result request(
|
||||||
|
String endpoint,
|
||||||
|
String model,
|
||||||
|
String key,
|
||||||
|
String prompt,
|
||||||
|
byte[] wav) {
|
||||||
|
HttpURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
String boundary = "transui-" + UUID.randomUUID();
|
||||||
|
byte[] requestBody = multipartBody(boundary, model, prompt, wav);
|
||||||
|
connection = (HttpURLConnection) new URL(endpointUrl(endpoint)).openConnection();
|
||||||
|
activeConnection = connection;
|
||||||
|
connection.setConnectTimeout(12_000);
|
||||||
|
connection.setReadTimeout(300_000);
|
||||||
|
connection.setRequestMethod("POST");
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
connection.setFixedLengthStreamingMode(requestBody.length);
|
||||||
|
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
|
||||||
|
connection.setRequestProperty("Accept", "application/json");
|
||||||
|
if (!key.isEmpty()) connection.setRequestProperty("Authorization", "Bearer " + key);
|
||||||
|
|
||||||
|
try (java.io.OutputStream output = connection.getOutputStream()) {
|
||||||
|
output.write(requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = connection.getResponseCode();
|
||||||
|
InputStream stream = status >= 200 && status < 300
|
||||||
|
? connection.getInputStream()
|
||||||
|
: connection.getErrorStream();
|
||||||
|
String response = stream == null ? "" : readResponse(stream);
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
return Result.error("Server returned HTTP " + status, SpeechRecognizer.ERROR_SERVER);
|
||||||
|
}
|
||||||
|
JSONObject object = new JSONObject(response);
|
||||||
|
if (!object.has("text") || object.isNull("text")) {
|
||||||
|
return Result.error("Response does not contain a text field", SpeechRecognizer.ERROR_SERVER);
|
||||||
|
}
|
||||||
|
return Result.success(TranscriptCleaner.clean(object.getString("text")));
|
||||||
|
} catch (IOException error) {
|
||||||
|
return Result.error("Network request failed", SpeechRecognizer.ERROR_NETWORK);
|
||||||
|
} catch (JSONException error) {
|
||||||
|
return Result.error("Server returned invalid JSON", SpeechRecognizer.ERROR_SERVER);
|
||||||
|
} finally {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
if (activeConnection == connection) activeConnection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String endpointUrl(String endpoint) {
|
||||||
|
String result = endpoint;
|
||||||
|
while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
|
||||||
|
if (result.contains("/v1/audio/transcriptions")) return result;
|
||||||
|
return result + "/v1/audio/transcriptions";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] multipartBody(String boundary, String model, String prompt, byte[] wav)
|
||||||
|
throws IOException {
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream(wav.length + 1024);
|
||||||
|
try (DataOutputStream output = new DataOutputStream(bytes)) {
|
||||||
|
writeField(output, boundary, "response_format", "json");
|
||||||
|
if (!model.isEmpty()) writeField(output, boundary, "model", model);
|
||||||
|
if (!prompt.isEmpty()) writeField(output, boundary, "prompt", prompt);
|
||||||
|
output.writeBytes("--" + boundary + "\r\n");
|
||||||
|
output.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n");
|
||||||
|
output.writeBytes("Content-Type: audio/wav\r\n\r\n");
|
||||||
|
output.write(wav);
|
||||||
|
output.writeBytes("\r\n--" + boundary + "--\r\n");
|
||||||
|
}
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeField(DataOutputStream output, String boundary, String name, String value)
|
||||||
|
throws IOException {
|
||||||
|
output.writeBytes("--" + boundary + "\r\n");
|
||||||
|
output.writeBytes("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n");
|
||||||
|
output.write(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.writeBytes("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readResponse(InputStream stream) throws IOException {
|
||||||
|
try (BufferedInputStream input = new BufferedInputStream(stream);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||||
|
byte[] buffer = new byte[4096];
|
||||||
|
int total = 0;
|
||||||
|
int count;
|
||||||
|
while ((count = input.read(buffer)) != -1) {
|
||||||
|
total += count;
|
||||||
|
if (total > MAX_RESPONSE_BYTES) throw new IOException("Response is too large");
|
||||||
|
output.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
return output.toString(StandardCharsets.UTF_8.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Result {
|
||||||
|
final String text;
|
||||||
|
final String error;
|
||||||
|
final int errorCode;
|
||||||
|
|
||||||
|
private Result(String text, String error, int errorCode) {
|
||||||
|
this.text = text;
|
||||||
|
this.error = error;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Result success(String text) {
|
||||||
|
return new Result(text, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Result error(String message, int errorCode) {
|
||||||
|
return new Result(null, message, errorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.inputmethodservice.InputMethodService;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.speech.SpeechRecognizer;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
public final class TransuiInputMethodService extends InputMethodService {
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
|
private TextView status;
|
||||||
|
private AmplitudeView amplitude;
|
||||||
|
private PushToTalkButton record;
|
||||||
|
private AppConfig config;
|
||||||
|
private VoiceRecordingController controller;
|
||||||
|
private boolean sessionActive;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onEvaluateFullscreenMode() {
|
||||||
|
// Voice input should stay attached to the calling app in landscape. Android's
|
||||||
|
// default extract editor is the large, blank text area shown above the IME.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public View onCreateInputView() {
|
||||||
|
FrameLayout parent = new FrameLayout(this);
|
||||||
|
View view = getLayoutInflater().inflate(R.layout.voice_input, parent, false);
|
||||||
|
status = view.findViewById(R.id.voice_status);
|
||||||
|
amplitude = view.findViewById(R.id.voice_amplitude);
|
||||||
|
record = view.findViewById(R.id.voice_record);
|
||||||
|
view.findViewById(R.id.voice_keyboard).setOnClickListener(button -> returnToKeyboard());
|
||||||
|
applySystemBarInsets(view);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySystemBarInsets(View inputView) {
|
||||||
|
int originalLeft = inputView.getPaddingLeft();
|
||||||
|
int originalTop = inputView.getPaddingTop();
|
||||||
|
int originalRight = inputView.getPaddingRight();
|
||||||
|
int originalBottom = inputView.getPaddingBottom();
|
||||||
|
inputView.setOnApplyWindowInsetsListener((view, insets) -> {
|
||||||
|
int left;
|
||||||
|
int right;
|
||||||
|
int bottom;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
android.graphics.Insets navigation = insets.getInsets(
|
||||||
|
WindowInsets.Type.navigationBars());
|
||||||
|
android.graphics.Insets gestures = insets.getInsets(
|
||||||
|
WindowInsets.Type.mandatorySystemGestures());
|
||||||
|
left = Math.max(navigation.left, gestures.left);
|
||||||
|
right = Math.max(navigation.right, gestures.right);
|
||||||
|
bottom = Math.max(navigation.bottom, gestures.bottom);
|
||||||
|
} else {
|
||||||
|
left = insets.getSystemWindowInsetLeft();
|
||||||
|
right = insets.getSystemWindowInsetRight();
|
||||||
|
bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
}
|
||||||
|
view.setPadding(
|
||||||
|
originalLeft + left,
|
||||||
|
originalTop,
|
||||||
|
originalRight + right,
|
||||||
|
originalBottom + bottom);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
inputView.post(inputView::requestApplyInsets);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStartInputView(android.view.inputmethod.EditorInfo info, boolean restarting) {
|
||||||
|
super.onStartInputView(info, restarting);
|
||||||
|
cancelSession();
|
||||||
|
config = new ConfigRepository(this).load();
|
||||||
|
configureRecordButton();
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
showUnavailable(R.string.recording_failed);
|
||||||
|
} else if (!config.hasUsableEndpoint()) {
|
||||||
|
showUnavailable(R.string.configure_first);
|
||||||
|
} else if (config.pushToTalk) {
|
||||||
|
status.setText(R.string.hold_to_talk);
|
||||||
|
} else {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFinishInputView(boolean finishingInput) {
|
||||||
|
cancelSession();
|
||||||
|
super.onFinishInputView(finishingInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
cancelSession();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureRecordButton() {
|
||||||
|
record.setEnabled(true);
|
||||||
|
record.setAlpha(1);
|
||||||
|
record.setOnClickListener(null);
|
||||||
|
record.setPushToTalkActions(null, null);
|
||||||
|
if (config.pushToTalk) {
|
||||||
|
record.setBackgroundResource(R.drawable.voice_pill_button);
|
||||||
|
resizeRecordButton(132, 64, 16);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.APPS);
|
||||||
|
record.setContentDescription(getString(R.string.hold_to_talk));
|
||||||
|
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
||||||
|
} else {
|
||||||
|
record.setBackgroundResource(R.drawable.voice_primary_button);
|
||||||
|
resizeRecordButton(64, 64, 19);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
|
record.setContentDescription(getString(R.string.stop));
|
||||||
|
record.setOnClickListener(view -> stopRecording());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
int width = Math.round(widthDp * density);
|
||||||
|
int height = Math.round(heightDp * density);
|
||||||
|
int padding = Math.round(paddingDp * density);
|
||||||
|
ViewGroup.LayoutParams parameters = record.getLayoutParams();
|
||||||
|
parameters.width = width;
|
||||||
|
parameters.height = height;
|
||||||
|
record.setLayoutParams(parameters);
|
||||||
|
record.setPadding(padding, padding, padding, padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startRecording() {
|
||||||
|
if (sessionActive) return;
|
||||||
|
sessionActive = true;
|
||||||
|
controller = new VoiceRecordingController(this, config, new ImeListener());
|
||||||
|
controller.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopRecording() {
|
||||||
|
if (!sessionActive || controller == null) return;
|
||||||
|
record.setEnabled(false);
|
||||||
|
record.setAlpha(0.4f);
|
||||||
|
amplitude.reset();
|
||||||
|
controller.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelSession() {
|
||||||
|
handler.removeCallbacksAndMessages(null);
|
||||||
|
if (controller != null) controller.cancel();
|
||||||
|
controller = null;
|
||||||
|
sessionActive = false;
|
||||||
|
if (amplitude != null) amplitude.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showUnavailable(int message) {
|
||||||
|
status.setText(message);
|
||||||
|
amplitude.reset();
|
||||||
|
record.setEnabled(false);
|
||||||
|
record.setAlpha(0.4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void returnToKeyboard() {
|
||||||
|
cancelSession();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
switchToPreviousInputMethod();
|
||||||
|
} else {
|
||||||
|
requestHideSelf(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ImeListener implements VoiceRecordingController.Listener {
|
||||||
|
@Override
|
||||||
|
public void onRecording() {
|
||||||
|
handler.post(() -> {
|
||||||
|
status.setText(R.string.listening);
|
||||||
|
if (!config.pushToTalk) {
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAmplitude(float level) {
|
||||||
|
handler.post(() -> amplitude.setAmplitude(level));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTranscribing() {
|
||||||
|
handler.post(() -> {
|
||||||
|
status.setText(R.string.transcribing);
|
||||||
|
amplitude.reset();
|
||||||
|
record.setEnabled(false);
|
||||||
|
record.setAlpha(0.4f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResult(String text) {
|
||||||
|
handler.post(() -> {
|
||||||
|
InputConnection connection = getCurrentInputConnection();
|
||||||
|
if (connection == null) {
|
||||||
|
showUnavailable(R.string.transcription_failed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection.commitText(text, 1);
|
||||||
|
returnToKeyboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(int errorCode) {
|
||||||
|
handler.post(() -> showUnavailable(
|
||||||
|
errorCode == SpeechRecognizer.ERROR_NETWORK
|
||||||
|
|| errorCode == SpeechRecognizer.ERROR_SERVER
|
||||||
|
? R.string.transcription_failed
|
||||||
|
: R.string.recording_failed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.RemoteException;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.speech.RecognitionService;
|
||||||
|
import android.speech.RecognitionSupport;
|
||||||
|
import android.speech.RecognizerIntent;
|
||||||
|
import android.speech.SpeechRecognizer;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
public final class TransuiRecognitionService extends RecognitionService {
|
||||||
|
private static final String TAG = "TransuiRecognition";
|
||||||
|
private static final int SAMPLE_RATE = 16_000;
|
||||||
|
private static final long MAX_RECORDING_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
private final Object sessionLock = new Object();
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private Session activeSession;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStartListening(Intent recognizerIntent, Callback callback) {
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppConfig config = new ConfigRepository(this).load();
|
||||||
|
if (!config.hasUsableEndpoint()) {
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_CLIENT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized (sessionLock) {
|
||||||
|
if (activeSession != null) {
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_RECOGNIZER_BUSY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeSession = new Session(callback, config);
|
||||||
|
executor.execute(activeSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStopListening(Callback callback) {
|
||||||
|
Session session;
|
||||||
|
synchronized (sessionLock) {
|
||||||
|
session = activeSession;
|
||||||
|
}
|
||||||
|
if (session != null) session.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCancel(Callback callback) {
|
||||||
|
Session session;
|
||||||
|
synchronized (sessionLock) {
|
||||||
|
session = activeSession;
|
||||||
|
}
|
||||||
|
if (session != null) session.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
public void onCheckRecognitionSupport(Intent recognizerIntent, SupportCallback callback) {
|
||||||
|
String language = recognizerIntent.getStringExtra(RecognizerIntent.EXTRA_LANGUAGE);
|
||||||
|
if (language == null || language.isEmpty()) language = Locale.getDefault().toLanguageTag();
|
||||||
|
callback.onSupportResult(new RecognitionSupport.Builder().addOnlineLanguage(language).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
Session session;
|
||||||
|
synchronized (sessionLock) {
|
||||||
|
session = activeSession;
|
||||||
|
}
|
||||||
|
if (session != null) session.cancel();
|
||||||
|
executor.shutdownNow();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearSession(Session session) {
|
||||||
|
synchronized (sessionLock) {
|
||||||
|
if (activeSession == session) activeSession = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendError(Callback callback, int error) {
|
||||||
|
try {
|
||||||
|
callback.error(error);
|
||||||
|
} catch (RemoteException ignored) {
|
||||||
|
// The caller has already gone away.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Session implements Runnable {
|
||||||
|
private final Callback callback;
|
||||||
|
private final AppConfig config;
|
||||||
|
private final AtomicBoolean stopRequested = new AtomicBoolean();
|
||||||
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
private final TranscriptionClient client = new TranscriptionClient();
|
||||||
|
|
||||||
|
Session(Callback callback, AppConfig config) {
|
||||||
|
this.callback = callback;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
byte[] pcm = captureAudio();
|
||||||
|
if (pcm == null || cancelled.get()) return;
|
||||||
|
sendEndOfSpeech();
|
||||||
|
if (cancelled.get()) return;
|
||||||
|
TranscriptionClient.Result result = client.transcribe(
|
||||||
|
config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
|
||||||
|
if (cancelled.get()) return;
|
||||||
|
if (result.text == null) {
|
||||||
|
Log.w(TAG, result.error);
|
||||||
|
sendError(callback, result.errorCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResults(result.text);
|
||||||
|
} finally {
|
||||||
|
clearSession(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
stopRequested.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
cancelled.set(true);
|
||||||
|
stopRequested.set(true);
|
||||||
|
client.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] captureAudio() {
|
||||||
|
int minimum = AudioRecord.getMinBufferSize(
|
||||||
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
|
||||||
|
if (minimum <= 0) {
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int bufferBytes = Math.max(minimum, 4096);
|
||||||
|
AudioRecord recorder;
|
||||||
|
try {
|
||||||
|
recorder = new AudioRecord.Builder()
|
||||||
|
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||||
|
.setAudioFormat(new AudioFormat.Builder()
|
||||||
|
.setSampleRate(SAMPLE_RATE)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
.build())
|
||||||
|
.setBufferSizeInBytes(bufferBytes)
|
||||||
|
.build();
|
||||||
|
} catch (IllegalArgumentException | SecurityException error) {
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
recorder.release();
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteArrayOutputStream pcm = new ByteArrayOutputStream(SAMPLE_RATE * 8);
|
||||||
|
short[] samples = new short[bufferBytes / 2];
|
||||||
|
boolean beginningSent = false;
|
||||||
|
long startedAt = SystemClock.elapsedRealtime();
|
||||||
|
try {
|
||||||
|
recorder.startRecording();
|
||||||
|
sendReady();
|
||||||
|
while (!cancelled.get()) {
|
||||||
|
int count = recorder.read(samples, 0, samples.length, AudioRecord.READ_BLOCKING);
|
||||||
|
if (count <= 0) {
|
||||||
|
if (!cancelled.get()) sendError(callback, SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
appendSamples(pcm, samples, count);
|
||||||
|
sendRms(rmsDb(samples, count));
|
||||||
|
if (!beginningSent) {
|
||||||
|
beginningSent = true;
|
||||||
|
sendBeginningOfSpeech();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopRequested.get()) break;
|
||||||
|
if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
|
||||||
|
}
|
||||||
|
} catch (IllegalStateException | SecurityException error) {
|
||||||
|
if (!cancelled.get()) sendError(callback, SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch (IllegalStateException ignored) {
|
||||||
|
// The recorder did not reach its recording state.
|
||||||
|
}
|
||||||
|
recorder.release();
|
||||||
|
}
|
||||||
|
if (cancelled.get()) return null;
|
||||||
|
if (pcm.size() == 0) {
|
||||||
|
sendError(callback, SpeechRecognizer.ERROR_NO_MATCH);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pcm.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendReady() {
|
||||||
|
try {
|
||||||
|
callback.readyForSpeech(new Bundle());
|
||||||
|
} catch (RemoteException error) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendBeginningOfSpeech() {
|
||||||
|
try {
|
||||||
|
callback.beginningOfSpeech();
|
||||||
|
} catch (RemoteException error) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendRms(float rms) {
|
||||||
|
try {
|
||||||
|
callback.rmsChanged(rms);
|
||||||
|
} catch (RemoteException error) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendEndOfSpeech() {
|
||||||
|
try {
|
||||||
|
callback.endOfSpeech();
|
||||||
|
} catch (RemoteException error) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendResults(String text) {
|
||||||
|
Bundle bundle = new Bundle();
|
||||||
|
ArrayList<String> results = new ArrayList<>();
|
||||||
|
results.add(text);
|
||||||
|
bundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results);
|
||||||
|
bundle.putFloatArray(SpeechRecognizer.CONFIDENCE_SCORES, new float[]{-1.0f});
|
||||||
|
try {
|
||||||
|
callback.results(bundle);
|
||||||
|
} catch (RemoteException ignored) {
|
||||||
|
// The caller has already gone away.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendSamples(ByteArrayOutputStream output, short[] samples, int count) {
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
int sample = samples[index];
|
||||||
|
output.write(sample & 0xff);
|
||||||
|
output.write((sample >>> 8) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float rmsDb(short[] samples, int count) {
|
||||||
|
double sum = 0;
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
double normalized = samples[index] / 32768.0;
|
||||||
|
sum += normalized * normalized;
|
||||||
|
}
|
||||||
|
double rms = Math.sqrt(sum / count);
|
||||||
|
return rms <= 0 ? -100.0f : (float) (20.0 * Math.log10(rms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.speech.SpeechRecognizer;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
final class VoiceRecordingController {
|
||||||
|
interface Listener {
|
||||||
|
void onRecording();
|
||||||
|
|
||||||
|
void onAmplitude(float amplitude);
|
||||||
|
|
||||||
|
void onTranscribing();
|
||||||
|
|
||||||
|
void onResult(String text);
|
||||||
|
|
||||||
|
void onError(int errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int SAMPLE_RATE = 16_000;
|
||||||
|
private static final long MAX_RECORDING_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final AppConfig config;
|
||||||
|
private final Listener listener;
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private final AtomicBoolean stopRequested = new AtomicBoolean();
|
||||||
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
private final AtomicBoolean started = new AtomicBoolean();
|
||||||
|
private final TranscriptionClient client = new TranscriptionClient();
|
||||||
|
private final AudioLevelMeter levelMeter = new AudioLevelMeter();
|
||||||
|
|
||||||
|
VoiceRecordingController(Context context, AppConfig config, Listener listener) {
|
||||||
|
this.context = context.getApplicationContext();
|
||||||
|
this.config = config;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (!started.compareAndSet(false, true)) return;
|
||||||
|
executor.execute(this::recordAndTranscribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
stopRequested.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
cancelled.set(true);
|
||||||
|
stopRequested.set(true);
|
||||||
|
client.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordAndTranscribe() {
|
||||||
|
try {
|
||||||
|
byte[] pcm = captureAudio();
|
||||||
|
if (pcm == null || cancelled.get()) return;
|
||||||
|
listener.onTranscribing();
|
||||||
|
TranscriptionClient.Result result = client.transcribe(
|
||||||
|
config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
|
||||||
|
if (cancelled.get()) return;
|
||||||
|
if (result.text != null) {
|
||||||
|
listener.onResult(result.text);
|
||||||
|
} else {
|
||||||
|
listener.onError(result.errorCode);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private byte[] captureAudio() {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
listener.onError(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int minimum = AudioRecord.getMinBufferSize(
|
||||||
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
|
||||||
|
if (minimum <= 0) {
|
||||||
|
listener.onError(SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bufferBytes = Math.max(minimum, 4096);
|
||||||
|
AudioRecord recorder;
|
||||||
|
try {
|
||||||
|
recorder = new AudioRecord.Builder()
|
||||||
|
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||||
|
.setAudioFormat(new AudioFormat.Builder()
|
||||||
|
.setSampleRate(SAMPLE_RATE)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
.build())
|
||||||
|
.setBufferSizeInBytes(bufferBytes)
|
||||||
|
.build();
|
||||||
|
} catch (IllegalArgumentException | SecurityException error) {
|
||||||
|
listener.onError(SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
recorder.release();
|
||||||
|
listener.onError(SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteArrayOutputStream pcm = new ByteArrayOutputStream(SAMPLE_RATE * 8);
|
||||||
|
// Keep meter updates quick even when AudioRecord asks for a large internal buffer.
|
||||||
|
short[] samples = new short[Math.min(bufferBytes / 2, 1024)];
|
||||||
|
long startedAt = SystemClock.elapsedRealtime();
|
||||||
|
try {
|
||||||
|
recorder.startRecording();
|
||||||
|
listener.onRecording();
|
||||||
|
while (!cancelled.get()) {
|
||||||
|
int count = recorder.read(samples, 0, samples.length, AudioRecord.READ_BLOCKING);
|
||||||
|
if (count <= 0) {
|
||||||
|
if (!cancelled.get()) listener.onError(SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
appendSamples(pcm, samples, count);
|
||||||
|
listener.onAmplitude(levelMeter.measure(samples, count));
|
||||||
|
if (stopRequested.get()) break;
|
||||||
|
if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
|
||||||
|
}
|
||||||
|
} catch (IllegalStateException | SecurityException error) {
|
||||||
|
if (!cancelled.get()) listener.onError(SpeechRecognizer.ERROR_AUDIO);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch (IllegalStateException ignored) {
|
||||||
|
// The recorder did not reach its recording state.
|
||||||
|
}
|
||||||
|
recorder.release();
|
||||||
|
}
|
||||||
|
if (cancelled.get()) return null;
|
||||||
|
if (pcm.size() == 0) {
|
||||||
|
listener.onError(SpeechRecognizer.ERROR_NO_MATCH);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return pcm.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendSamples(ByteArrayOutputStream output, short[] samples, int count) {
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
int sample = samples[index];
|
||||||
|
output.write(sample & 0xff);
|
||||||
|
output.write((sample >>> 8) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
|
||||||
|
final class WavEncoder {
|
||||||
|
private static final int HEADER_SIZE = 44;
|
||||||
|
|
||||||
|
private WavEncoder() {}
|
||||||
|
|
||||||
|
static byte[] encodePcm16Mono(byte[] pcm, int sampleRate) {
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream(HEADER_SIZE + pcm.length);
|
||||||
|
writeAscii(output, "RIFF");
|
||||||
|
writeInt(output, 36 + pcm.length);
|
||||||
|
writeAscii(output, "WAVE");
|
||||||
|
writeAscii(output, "fmt ");
|
||||||
|
writeInt(output, 16);
|
||||||
|
writeShort(output, 1);
|
||||||
|
writeShort(output, 1);
|
||||||
|
writeInt(output, sampleRate);
|
||||||
|
writeInt(output, sampleRate * 2);
|
||||||
|
writeShort(output, 2);
|
||||||
|
writeShort(output, 16);
|
||||||
|
writeAscii(output, "data");
|
||||||
|
writeInt(output, pcm.length);
|
||||||
|
output.write(pcm, 0, pcm.length);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeAscii(ByteArrayOutputStream output, String value) {
|
||||||
|
for (int index = 0; index < value.length(); index++) output.write(value.charAt(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeShort(ByteArrayOutputStream output, int value) {
|
||||||
|
output.write(value & 0xff);
|
||||||
|
output.write((value >>> 8) & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeInt(ByteArrayOutputStream output, int value) {
|
||||||
|
output.write(value & 0xff);
|
||||||
|
output.write((value >>> 8) & 0xff);
|
||||||
|
output.write((value >>> 16) & 0xff);
|
||||||
|
output.write((value >>> 24) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M20,5H4C2.89,5 2,5.89 2,7v10c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V7c0,-1.11 -0.89,-2 -2,-2zM11,8h2v2h-2V8zM11,11h2v2h-2v-2zM8,8h2v2H8V8zM8,11h2v2H8v-2zM7,13H5v-2h2v2zM7,10H5V8h2v2zM17,17H7v-2h10v2zM17,13h-3v-2h3v2zM17,10h-3V8h3v2zM19,13h-1v-2h1v2zM19,10h-1V8h1v2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#008D87"
|
||||||
|
android:pathData="M54,4A50,50 0,1 0,54 104A50,50 0,1 0,54 4" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M25,49h8v10h-8zM38,38h8v32h-8zM51,27h8v54h-8zM64,38h8v32h-8zM77,49h8v10h-8z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="@color/paper" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/line" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:color="@color/ripple_on_signal">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/signal" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</ripple>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:color="@color/ripple_on_signal">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="oval">
|
||||||
|
<solid android:color="@color/signal" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</ripple>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:color="@color/ripple_signal">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="oval">
|
||||||
|
<solid android:color="@color/mist" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</ripple>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingTop="28dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingBottom="32dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/title"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="27sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:background="@drawable/panel"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="54dp"
|
||||||
|
android:background="@color/signal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/provider_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/SectionLabel"
|
||||||
|
android:text="@string/server" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/endpoint"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/endpoint"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/model"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/model"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/api_key"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/api_key"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textPassword" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
style="@style/SectionLabel"
|
||||||
|
android:text="@string/request" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/prompt"
|
||||||
|
style="@style/Field"
|
||||||
|
android:hint="@string/prompt"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:inputType="textCapSentences|textMultiLine"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:singleLine="false" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
android:id="@+id/push_to_talk"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:text="@string/push_to_talk"
|
||||||
|
android:textColor="@color/ink" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/save"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="@string/save" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/microphone"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/allow_microphone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/language_settings"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/open_language_settings" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/panel"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/voice_input"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="21sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@color/signal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/recognition_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/listening"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="18sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/recognition_elapsed"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/elapsed_zero"
|
||||||
|
android:textColor="@color/slate"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<io.github.transui.android.AmplitudeView
|
||||||
|
android:id="@+id/recognition_amplitude"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="10dp" />
|
||||||
|
|
||||||
|
<io.github.transui.android.PushToTalkButton
|
||||||
|
android:id="@+id/recognition_record"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:background="@drawable/voice_primary_button"
|
||||||
|
android:contentDescription="@string/stop"
|
||||||
|
android:padding="16dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/recognition_cancel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/cancel" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/paper"
|
||||||
|
android:orientation="vertical"
|
||||||
|
tools:ignore="Overdraw">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/line" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="18dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingEnd="18dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:background="@color/signal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/voice_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:paddingStart="10dp"
|
||||||
|
android:paddingEnd="10dp"
|
||||||
|
android:text="@string/listening"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<io.github.transui.android.AmplitudeView
|
||||||
|
android:id="@+id/voice_amplitude"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="54dp"
|
||||||
|
android:layout_marginStart="28dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginEnd="28dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:paddingTop="5dp"
|
||||||
|
android:paddingBottom="5dp" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="82dp"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="10dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/voice_keyboard"
|
||||||
|
android:layout_width="52dp"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:layout_gravity="start|center_vertical"
|
||||||
|
android:background="@drawable/voice_secondary_button"
|
||||||
|
android:contentDescription="@string/keyboard"
|
||||||
|
android:padding="13dp"
|
||||||
|
android:src="@drawable/ic_keyboard"
|
||||||
|
android:tint="@color/ink" />
|
||||||
|
|
||||||
|
<io.github.transui.android.PushToTalkButton
|
||||||
|
android:id="@+id/voice_record"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/voice_primary_button"
|
||||||
|
android:contentDescription="@string/stop"
|
||||||
|
android:padding="21dp" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ink">#EAF1F2</color>
|
||||||
|
<color name="slate">#A8B7BC</color>
|
||||||
|
<color name="signal">#42C9C1</color>
|
||||||
|
<color name="mist">#11191D</color>
|
||||||
|
<color name="paper">#192329</color>
|
||||||
|
<color name="line">#34444B</color>
|
||||||
|
<color name="on_signal">#142027</color>
|
||||||
|
<color name="ripple_on_signal">#3DFFFFFF</color>
|
||||||
|
<color name="ripple_signal">#3342C9C1</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:statusBarColor">@color/mist</item>
|
||||||
|
<item name="android:navigationBarColor">@color/paper</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
|
<item name="android:windowBackground">@color/mist</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="RecognitionTheme" parent="android:style/Theme.Material.Dialog.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowCloseOnTouchOutside">false</item>
|
||||||
|
<item name="android:windowMinWidthMajor">82%</item>
|
||||||
|
<item name="android:windowMinWidthMinor">82%</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ink">#142027</color>
|
||||||
|
<color name="slate">#56676F</color>
|
||||||
|
<color name="signal">#008D87</color>
|
||||||
|
<color name="mist">#F2F6F7</color>
|
||||||
|
<color name="paper">#FFFFFF</color>
|
||||||
|
<color name="line">#D5E0E3</color>
|
||||||
|
<color name="on_signal">#FFFFFF</color>
|
||||||
|
<color name="ripple_on_signal">#3DFFFFFF</color>
|
||||||
|
<color name="ripple_signal">#33008D87</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Transui</string>
|
||||||
|
<string name="provider_name">Transui speech recognition</string>
|
||||||
|
<string name="title">Transui voice input</string>
|
||||||
|
<string name="status_selected">Selected as voice input</string>
|
||||||
|
<string name="status_not_selected">Select Transui as voice input</string>
|
||||||
|
<string name="server">SERVER</string>
|
||||||
|
<string name="request">REQUEST</string>
|
||||||
|
<string name="endpoint">Transcription endpoint</string>
|
||||||
|
<string name="model">Model</string>
|
||||||
|
<string name="api_key">API key</string>
|
||||||
|
<string name="prompt">Prompt</string>
|
||||||
|
<string name="push_to_talk">Push to talk</string>
|
||||||
|
<string name="save">Save settings</string>
|
||||||
|
<string name="open_language_settings">Open language settings</string>
|
||||||
|
<string name="allow_microphone">Allow microphone</string>
|
||||||
|
<string name="microphone_allowed">Microphone allowed</string>
|
||||||
|
<string name="settings_saved">Settings saved</string>
|
||||||
|
<string name="settings_save_failed">Could not save API keys</string>
|
||||||
|
<string name="language_settings_unavailable">Language settings are unavailable</string>
|
||||||
|
<string name="voice_input">Transui voice input</string>
|
||||||
|
<string name="listening">Listening</string>
|
||||||
|
<string name="hold_to_talk">Hold to talk</string>
|
||||||
|
<string name="transcribing">Transcribing</string>
|
||||||
|
<string name="stop">Stop</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="keyboard">Keyboard</string>
|
||||||
|
<string name="recording_failed">Could not record</string>
|
||||||
|
<string name="transcription_failed">Could not transcribe</string>
|
||||||
|
<string name="configure_first">Configure a transcription endpoint first</string>
|
||||||
|
<string name="elapsed_zero">00:00</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:statusBarColor">@color/mist</item>
|
||||||
|
<item name="android:navigationBarColor">@color/paper</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
|
<item name="android:windowBackground">@color/mist</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Field">
|
||||||
|
<item name="android:layout_width">match_parent</item>
|
||||||
|
<item name="android:layout_height">wrap_content</item>
|
||||||
|
<item name="android:layout_marginTop">4dp</item>
|
||||||
|
<item name="android:singleLine">true</item>
|
||||||
|
<item name="android:textColor">@color/ink</item>
|
||||||
|
<item name="android:textColorHint">@color/slate</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="SectionLabel">
|
||||||
|
<item name="android:layout_width">match_parent</item>
|
||||||
|
<item name="android:layout_height">wrap_content</item>
|
||||||
|
<item name="android:layout_marginTop">22dp</item>
|
||||||
|
<item name="android:textColor">@color/signal</item>
|
||||||
|
<item name="android:textSize">12sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
<item name="android:letterSpacing">0.12</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="RecognitionTheme" parent="android:style/Theme.Material.Light.Dialog.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowCloseOnTouchOutside">false</item>
|
||||||
|
<item name="android:windowMinWidthMajor">82%</item>
|
||||||
|
<item name="android:windowMinWidthMinor">82%</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:settingsActivity="io.github.transui.android.MainActivity"
|
||||||
|
android:supportsSwitchingToNextInputMethod="true">
|
||||||
|
<subtype
|
||||||
|
android:imeSubtypeLocale=""
|
||||||
|
android:imeSubtypeMode="voice"
|
||||||
|
android:isAuxiliary="true"
|
||||||
|
android:label="@string/voice_input"
|
||||||
|
android:overridesImplicitlyEnabledSubtype="true" />
|
||||||
|
</input-method>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<recognition-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:settingsActivity="io.github.transui.android.MainActivity" />
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public final class AudioLevelMeterTest {
|
||||||
|
@Test
|
||||||
|
public void silenceStaysAtZero() {
|
||||||
|
AudioLevelMeter meter = new AudioLevelMeter();
|
||||||
|
|
||||||
|
assertEquals(0, meter.measure(new short[1024], 1024), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void speechRisesAboveQuietMicrophoneInput() {
|
||||||
|
AudioLevelMeter meter = new AudioLevelMeter();
|
||||||
|
short[] quiet = alternatingSamples(8);
|
||||||
|
short[] speech = alternatingSamples(1200);
|
||||||
|
|
||||||
|
float quietLevel = meter.measure(quiet, quiet.length);
|
||||||
|
float speechLevel = meter.measure(speech, speech.length);
|
||||||
|
|
||||||
|
assertTrue(speechLevel > quietLevel + 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[] alternatingSamples(int amplitude) {
|
||||||
|
short[] samples = new short[1024];
|
||||||
|
for (int index = 0; index < samples.length; index++) {
|
||||||
|
samples[index] = (short) (index % 2 == 0 ? amplitude : -amplitude);
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public final class TranscriptCleanerTest {
|
||||||
|
@Test
|
||||||
|
public void removesObservedQwenLanguagePrefixes() {
|
||||||
|
assertEquals("What do you think?", TranscriptCleaner.clean(
|
||||||
|
"language English<astext>What do you think?"));
|
||||||
|
assertEquals("\u6765\u8ba9\u4f60\u53d1\u73b0\u4ed6\u4eec\u53ef\u80fd\u7684\u4e0d\u7684\u6807\u7b7e\u3002", TranscriptCleaner.clean(
|
||||||
|
"language Chinese<astext>\u6765\u8ba9\u4f60\u53d1\u73b0\u4ed6\u4eec\u53ef\u80fd\u7684\u4e0d\u7684\u6807\u7b7e\u3002"));
|
||||||
|
assertEquals("Ich habe keine Idee.", TranscriptCleaner.clean(
|
||||||
|
"language German<asr_text>Ich habe keine Idee."));
|
||||||
|
assertEquals("\u3042\u308a\u304c\u3068\u3046\u3054\u3056\u3044\u307e\u3059\u3002", TranscriptCleaner.clean(
|
||||||
|
"language Japanese<asr_text>\u3042\u308a\u304c\u3068\u3046\u3054\u3056\u3044\u307e\u3059\u3002"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removesPrefixAfterLeadingWhitespace() {
|
||||||
|
assertEquals("Hello", TranscriptCleaner.clean(" \n\tlanguage English<asr_text>Hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void keepsOrdinaryTranscript() {
|
||||||
|
assertEquals("Language matters.", TranscriptCleaner.clean("Language matters."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void keepsIncompleteQwenPrefix() {
|
||||||
|
assertEquals("language English", TranscriptCleaner.clean("language English"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertArrayEquals;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public final class WavEncoderTest {
|
||||||
|
@Test
|
||||||
|
public void writesPcm16MonoHeaderAndPayload() {
|
||||||
|
byte[] pcm = new byte[]{1, 2, 3, 4};
|
||||||
|
byte[] wav = WavEncoder.encodePcm16Mono(pcm, 16_000);
|
||||||
|
|
||||||
|
assertEquals("RIFF", ascii(wav, 0, 4));
|
||||||
|
assertEquals(40, littleEndianInt(wav, 4));
|
||||||
|
assertEquals("WAVE", ascii(wav, 8, 4));
|
||||||
|
assertEquals(16_000, littleEndianInt(wav, 24));
|
||||||
|
assertEquals(32_000, littleEndianInt(wav, 28));
|
||||||
|
assertEquals(4, littleEndianInt(wav, 40));
|
||||||
|
assertArrayEquals(pcm, new byte[]{wav[44], wav[45], wav[46], wav[47]});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ascii(byte[] bytes, int offset, int length) {
|
||||||
|
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int littleEndianInt(byte[] bytes, int offset) {
|
||||||
|
return (bytes[offset] & 0xff)
|
||||||
|
| ((bytes[offset + 1] & 0xff) << 8)
|
||||||
|
| ((bytes[offset + 2] & 0xff) << 16)
|
||||||
|
| ((bytes[offset + 3] & 0xff) << 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.9.2' apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=false
|
||||||
|
android.useAndroidX=false
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "TransuiAndroid"
|
||||||
|
include ':app'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DerivedData/
|
||||||
|
Transui.xcodeproj/
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Transui</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.github.transui.macos</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Transui</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>15.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Transui records speech to transcribe it.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
|
enum RecorderState: Equatable {
|
||||||
|
case idle
|
||||||
|
case preparing
|
||||||
|
case recording
|
||||||
|
case transcribing
|
||||||
|
case success(String)
|
||||||
|
case failure(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppModel: ObservableObject {
|
||||||
|
@Published private(set) var state: RecorderState = .idle
|
||||||
|
@Published private(set) var level = 0.0
|
||||||
|
@Published private(set) var elapsed: TimeInterval = 0
|
||||||
|
@Published private(set) var startsAtLogin = SMAppService.mainApp.status == .enabled
|
||||||
|
@Published private(set) var shortcutError: String?
|
||||||
|
|
||||||
|
let settings = AppSettings()
|
||||||
|
private let audioRecorder = AudioRecorder()
|
||||||
|
private let transcriptionClient = TranscriptionClient()
|
||||||
|
private let hotkeyManager = HotkeyManager()
|
||||||
|
private lazy var overlay = OverlayPanelController(model: self)
|
||||||
|
private var elapsedTimer: Timer?
|
||||||
|
private var startedAt: Date?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var registeredShortcut: HotkeyShortcut?
|
||||||
|
private var restoringShortcut = false
|
||||||
|
private var noticeID = UUID()
|
||||||
|
private var recordingRequestID: UUID?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
audioRecorder.onLevel = { [weak self] level in self?.level = level }
|
||||||
|
registerHotkey(settings.shortcut)
|
||||||
|
settings.$shortcut
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] shortcut in self?.registerHotkey(shortcut) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRecording: Bool { state == .recording || state == .preparing }
|
||||||
|
var isWorking: Bool { isRecording || state == .transcribing }
|
||||||
|
|
||||||
|
func toggleRecording() {
|
||||||
|
switch state {
|
||||||
|
case .recording:
|
||||||
|
stopAndTranscribe()
|
||||||
|
case .preparing:
|
||||||
|
cancelRecording()
|
||||||
|
case .idle, .success, .failure:
|
||||||
|
Task { await startRecording() }
|
||||||
|
case .transcribing:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRecording() {
|
||||||
|
guard isRecording else { return }
|
||||||
|
elapsedTimer?.invalidate()
|
||||||
|
elapsedTimer = nil
|
||||||
|
recordingRequestID = nil
|
||||||
|
audioRecorder.cancel()
|
||||||
|
state = .idle
|
||||||
|
overlay.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStartAtLogin(_ enabled: Bool) {
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
try SMAppService.mainApp.register()
|
||||||
|
} else {
|
||||||
|
try SMAppService.mainApp.unregister()
|
||||||
|
}
|
||||||
|
let status = SMAppService.mainApp.status
|
||||||
|
startsAtLogin = status == .enabled
|
||||||
|
if enabled, status == .requiresApproval {
|
||||||
|
showFailure("Allow Transui in Login Items.")
|
||||||
|
SMAppService.openSystemSettingsLoginItems()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
startsAtLogin = SMAppService.mainApp.status == .enabled
|
||||||
|
if isWorking {
|
||||||
|
NSSound.beep()
|
||||||
|
} else {
|
||||||
|
showFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMicrophoneSettings() {
|
||||||
|
openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAccessibilitySettings() {
|
||||||
|
openSystemSettings(
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRecording() async {
|
||||||
|
guard !transcriptionPlan.endpoints.isEmpty else {
|
||||||
|
showFailure("Set a transcription endpoint in Settings.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .preparing
|
||||||
|
overlay.show()
|
||||||
|
let requestID = UUID()
|
||||||
|
recordingRequestID = requestID
|
||||||
|
do {
|
||||||
|
try await audioRecorder.start()
|
||||||
|
guard recordingRequestID == requestID else {
|
||||||
|
audioRecorder.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed = 0
|
||||||
|
startedAt = Date()
|
||||||
|
elapsedTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, let startedAt = self.startedAt else { return }
|
||||||
|
self.elapsed = Date().timeIntervalSince(startedAt)
|
||||||
|
if self.elapsed >= 300 {
|
||||||
|
self.stopAndTranscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = .recording
|
||||||
|
} catch {
|
||||||
|
recordingRequestID = nil
|
||||||
|
audioRecorder.cancel()
|
||||||
|
showFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAndTranscribe() {
|
||||||
|
guard let recording = audioRecorder.stop() else {
|
||||||
|
showFailure("No recording was captured.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordingRequestID = nil
|
||||||
|
elapsedTimer?.invalidate()
|
||||||
|
elapsedTimer = nil
|
||||||
|
state = .transcribing
|
||||||
|
Task {
|
||||||
|
defer { try? FileManager.default.removeItem(at: recording) }
|
||||||
|
do {
|
||||||
|
let text = try await transcriptionClient.transcribe(
|
||||||
|
recording: recording,
|
||||||
|
plan: transcriptionPlan,
|
||||||
|
prompt: settings.prompt
|
||||||
|
)
|
||||||
|
let result = TranscriptOutput.deliver(text, to: settings.destination)
|
||||||
|
switch result {
|
||||||
|
case .typed: showSuccess("Typed at cursor")
|
||||||
|
case .copied: showSuccess("Copied to clipboard")
|
||||||
|
case .copiedBecauseAccessibilityIsOff:
|
||||||
|
showSuccess("Copied. Accessibility is off")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showFailure(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerHotkey(_ shortcut: HotkeyShortcut) {
|
||||||
|
if restoringShortcut {
|
||||||
|
restoringShortcut = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hotkeyManager.register(shortcut, action: { [weak self] in self?.toggleRecording() }) {
|
||||||
|
registeredShortcut = shortcut
|
||||||
|
shortcutError = nil
|
||||||
|
} else {
|
||||||
|
shortcutError = "That shortcut is already in use."
|
||||||
|
if let registeredShortcut {
|
||||||
|
restoringShortcut = true
|
||||||
|
settings.shortcut = registeredShortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var transcriptionPlan: TranscriptionPlan {
|
||||||
|
TranscriptionPlan(
|
||||||
|
hosted: TranscriptionEndpoint(
|
||||||
|
label: "Hosted",
|
||||||
|
endpoint: settings.hostedEndpoint,
|
||||||
|
model: settings.hostedModel,
|
||||||
|
key: settings.hostedKey
|
||||||
|
),
|
||||||
|
local: TranscriptionEndpoint(
|
||||||
|
label: "Local",
|
||||||
|
endpoint: settings.localEndpoint,
|
||||||
|
model: settings.localModel,
|
||||||
|
key: settings.localKey
|
||||||
|
),
|
||||||
|
hostedFirst: settings.hostedFirst
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSuccess(_ message: String) {
|
||||||
|
state = .success(message)
|
||||||
|
overlay.show()
|
||||||
|
hideOverlayAfterDelay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showFailure(_ message: String) {
|
||||||
|
elapsedTimer?.invalidate()
|
||||||
|
elapsedTimer = nil
|
||||||
|
state = .failure(message)
|
||||||
|
overlay.show()
|
||||||
|
hideOverlayAfterDelay(seconds: 3.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideOverlayAfterDelay(seconds: Double = 1.6) {
|
||||||
|
let id = UUID()
|
||||||
|
noticeID = id
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(seconds))
|
||||||
|
guard noticeID == id else { return }
|
||||||
|
guard case .success = state else {
|
||||||
|
if case .failure = state {
|
||||||
|
state = .idle
|
||||||
|
overlay.hide()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .idle
|
||||||
|
overlay.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSystemSettings(_ value: String) {
|
||||||
|
guard let url = URL(string: value) else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum TranscriptDestination: String, CaseIterable, Identifiable {
|
||||||
|
case cursor
|
||||||
|
case clipboard
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .cursor: "Type at cursor"
|
||||||
|
case .clipboard: "Copy to clipboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppSettings: ObservableObject {
|
||||||
|
private enum Key {
|
||||||
|
static let hostedEndpoint = "endpoint"
|
||||||
|
static let hostedModel = "model"
|
||||||
|
static let localEndpoint = "local_endpoint"
|
||||||
|
static let localModel = "local_model"
|
||||||
|
static let hostedFirst = "hosted_first"
|
||||||
|
static let prompt = "prompt"
|
||||||
|
static let destination = "destination"
|
||||||
|
static let shortcut = "shortcut"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
@Published var hostedEndpoint: String {
|
||||||
|
didSet { defaults.set(hostedEndpoint, forKey: Key.hostedEndpoint) }
|
||||||
|
}
|
||||||
|
@Published var hostedModel: String {
|
||||||
|
didSet { defaults.set(hostedModel, forKey: Key.hostedModel) }
|
||||||
|
}
|
||||||
|
@Published var localEndpoint: String {
|
||||||
|
didSet { defaults.set(localEndpoint, forKey: Key.localEndpoint) }
|
||||||
|
}
|
||||||
|
@Published var localModel: String {
|
||||||
|
didSet { defaults.set(localModel, forKey: Key.localModel) }
|
||||||
|
}
|
||||||
|
@Published var hostedFirst: Bool {
|
||||||
|
didSet { defaults.set(hostedFirst, forKey: Key.hostedFirst) }
|
||||||
|
}
|
||||||
|
@Published var prompt: String { didSet { defaults.set(prompt, forKey: Key.prompt) } }
|
||||||
|
@Published var destination: TranscriptDestination {
|
||||||
|
didSet { defaults.set(destination.rawValue, forKey: Key.destination) }
|
||||||
|
}
|
||||||
|
@Published var shortcut: HotkeyShortcut {
|
||||||
|
didSet {
|
||||||
|
if let data = try? JSONEncoder().encode(shortcut) {
|
||||||
|
defaults.set(data, forKey: Key.shortcut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
hostedEndpoint = defaults.string(forKey: Key.hostedEndpoint) ?? ""
|
||||||
|
hostedModel = defaults.string(forKey: Key.hostedModel) ?? ""
|
||||||
|
localEndpoint = defaults.string(forKey: Key.localEndpoint) ?? "http://localhost:8080"
|
||||||
|
localModel = defaults.string(forKey: Key.localModel) ?? ""
|
||||||
|
hostedFirst = defaults.object(forKey: Key.hostedFirst) as? Bool ?? true
|
||||||
|
prompt = defaults.string(forKey: Key.prompt) ?? ""
|
||||||
|
destination =
|
||||||
|
TranscriptDestination(
|
||||||
|
rawValue: defaults.string(forKey: Key.destination) ?? ""
|
||||||
|
) ?? .cursor
|
||||||
|
if let data = defaults.data(forKey: Key.shortcut),
|
||||||
|
let saved = try? JSONDecoder().decode(HotkeyShortcut.self, from: data)
|
||||||
|
{
|
||||||
|
shortcut = saved
|
||||||
|
} else {
|
||||||
|
shortcut = .defaultShortcut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostedKey: String {
|
||||||
|
get { KeychainStore.read(.hosted) }
|
||||||
|
set { KeychainStore.write(newValue, account: .hosted) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var localKey: String {
|
||||||
|
get { KeychainStore.read(.local) }
|
||||||
|
set { KeychainStore.write(newValue, account: .local) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum RecordingError: LocalizedError {
|
||||||
|
case microphoneDenied
|
||||||
|
case couldNotStart
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .microphoneDenied: "Microphone access is off. Enable it in System Settings."
|
||||||
|
case .couldNotStart: "The microphone could not start recording."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private var meterTimer: Timer?
|
||||||
|
private(set) var fileURL: URL?
|
||||||
|
var onLevel: ((Double) -> Void)?
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
guard await Self.requestPermission() else { throw RecordingError.microphoneDenied }
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("transui-\(UUID().uuidString).wav")
|
||||||
|
self.fileURL = fileURL
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVLinearPCMBitDepthKey: 16,
|
||||||
|
AVLinearPCMIsFloatKey: false,
|
||||||
|
AVLinearPCMIsBigEndianKey: false,
|
||||||
|
]
|
||||||
|
let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
|
||||||
|
recorder.delegate = self
|
||||||
|
recorder.isMeteringEnabled = true
|
||||||
|
guard recorder.prepareToRecord(), recorder.record() else {
|
||||||
|
throw RecordingError.couldNotStart
|
||||||
|
}
|
||||||
|
self.recorder = recorder
|
||||||
|
meterTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.updateLevel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() -> URL? {
|
||||||
|
meterTimer?.invalidate()
|
||||||
|
meterTimer = nil
|
||||||
|
recorder?.stop()
|
||||||
|
recorder = nil
|
||||||
|
onLevel?(0)
|
||||||
|
let url = fileURL
|
||||||
|
fileURL = nil
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
let url = stop()
|
||||||
|
if let url { try? FileManager.default.removeItem(at: url) }
|
||||||
|
fileURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLevel() {
|
||||||
|
guard let recorder else { return }
|
||||||
|
recorder.updateMeters()
|
||||||
|
let decibels = Double(recorder.averagePower(forChannel: 0))
|
||||||
|
let normalized = max(0, min(1, pow(10, decibels / 32)))
|
||||||
|
onLevel?(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestPermission() async -> Bool {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
|
case .authorized: true
|
||||||
|
case .denied, .restricted: false
|
||||||
|
case .notDetermined:
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { allowed in
|
||||||
|
continuation.resume(returning: allowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@unknown default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Carbon.HIToolbox
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class HotkeyManager {
|
||||||
|
private var hotkey: EventHotKeyRef?
|
||||||
|
private var handler: EventHandlerRef?
|
||||||
|
private var action: (() -> Void)?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
var type = EventTypeSpec(
|
||||||
|
eventClass: OSType(kEventClassKeyboard),
|
||||||
|
eventKind: UInt32(kEventHotKeyPressed)
|
||||||
|
)
|
||||||
|
InstallEventHandler(
|
||||||
|
GetApplicationEventTarget(),
|
||||||
|
{ _, event, context in
|
||||||
|
guard let event, let context else { return noErr }
|
||||||
|
var identifier = EventHotKeyID()
|
||||||
|
let status = GetEventParameter(
|
||||||
|
event,
|
||||||
|
EventParamName(kEventParamDirectObject),
|
||||||
|
EventParamType(typeEventHotKeyID),
|
||||||
|
nil,
|
||||||
|
MemoryLayout<EventHotKeyID>.size,
|
||||||
|
nil,
|
||||||
|
&identifier
|
||||||
|
)
|
||||||
|
guard status == noErr, identifier.id == 1 else { return status }
|
||||||
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(context).takeUnretainedValue()
|
||||||
|
DispatchQueue.main.async { manager.action?() }
|
||||||
|
return noErr
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
&type,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
&handler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
unregister()
|
||||||
|
if let handler { RemoveEventHandler(handler) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func register(_ shortcut: HotkeyShortcut, action: @escaping () -> Void) -> Bool {
|
||||||
|
let identifier = EventHotKeyID(signature: Self.signature, id: 1)
|
||||||
|
var candidate: EventHotKeyRef?
|
||||||
|
let status = RegisterEventHotKey(
|
||||||
|
shortcut.keyCode,
|
||||||
|
shortcut.modifiers,
|
||||||
|
identifier,
|
||||||
|
GetApplicationEventTarget(),
|
||||||
|
0,
|
||||||
|
&candidate
|
||||||
|
)
|
||||||
|
guard status == noErr, let candidate else { return false }
|
||||||
|
unregister()
|
||||||
|
hotkey = candidate
|
||||||
|
self.action = action
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unregister() {
|
||||||
|
if let hotkey { UnregisterEventHotKey(hotkey) }
|
||||||
|
hotkey = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let signature: OSType = 0x5452_5549 // TRUI
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HotkeyRecorder: NSViewRepresentable {
|
||||||
|
@Binding var shortcut: HotkeyShortcut
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> HotkeyCaptureView {
|
||||||
|
let view = HotkeyCaptureView()
|
||||||
|
view.onShortcut = { shortcut = $0 }
|
||||||
|
view.stringValue = shortcut.displayName
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ view: HotkeyCaptureView, context: Context) {
|
||||||
|
view.onShortcut = { shortcut = $0 }
|
||||||
|
if view.window?.firstResponder !== view {
|
||||||
|
view.stringValue = shortcut.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HotkeyCaptureView: NSTextField {
|
||||||
|
var onShortcut: ((HotkeyShortcut) -> Void)?
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
isEditable = false
|
||||||
|
isSelectable = false
|
||||||
|
isBezeled = true
|
||||||
|
bezelStyle = .roundedBezel
|
||||||
|
alignment = .center
|
||||||
|
font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .medium)
|
||||||
|
toolTip = "Click, then press a shortcut"
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { nil }
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
window?.makeFirstResponder(self)
|
||||||
|
stringValue = "Type shortcut"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard let shortcut = HotkeyShortcut(event: event) else {
|
||||||
|
NSSound.beep()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stringValue = shortcut.displayName
|
||||||
|
onShortcut?(shortcut)
|
||||||
|
window?.makeFirstResponder(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
needsDisplay = true
|
||||||
|
return super.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
|
||||||
|
struct HotkeyShortcut: Codable, Equatable {
|
||||||
|
let keyCode: UInt32
|
||||||
|
let modifiers: UInt32
|
||||||
|
|
||||||
|
static let defaultShortcut = HotkeyShortcut(
|
||||||
|
keyCode: UInt32(kVK_ANSI_S),
|
||||||
|
modifiers: UInt32(cmdKey | optionKey)
|
||||||
|
)
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
var result = ""
|
||||||
|
if modifiers & UInt32(controlKey) != 0 { result += "⌃" }
|
||||||
|
if modifiers & UInt32(optionKey) != 0 { result += "⌥" }
|
||||||
|
if modifiers & UInt32(shiftKey) != 0 { result += "⇧" }
|
||||||
|
if modifiers & UInt32(cmdKey) != 0 { result += "⌘" }
|
||||||
|
result += Self.keyNames[keyCode] ?? "Key \(keyCode)"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
init(keyCode: UInt32, modifiers: UInt32) {
|
||||||
|
self.keyCode = keyCode
|
||||||
|
self.modifiers = modifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(event: NSEvent) {
|
||||||
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||||
|
var carbonModifiers: UInt32 = 0
|
||||||
|
if flags.contains(.command) { carbonModifiers |= UInt32(cmdKey) }
|
||||||
|
if flags.contains(.option) { carbonModifiers |= UInt32(optionKey) }
|
||||||
|
if flags.contains(.control) { carbonModifiers |= UInt32(controlKey) }
|
||||||
|
if flags.contains(.shift) { carbonModifiers |= UInt32(shiftKey) }
|
||||||
|
guard carbonModifiers != 0 else { return nil }
|
||||||
|
self.init(keyCode: UInt32(event.keyCode), modifiers: carbonModifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let keyNames: [UInt32: String] = [
|
||||||
|
UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B",
|
||||||
|
UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D",
|
||||||
|
UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F",
|
||||||
|
UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H",
|
||||||
|
UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J",
|
||||||
|
UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
|
||||||
|
UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N",
|
||||||
|
UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P",
|
||||||
|
UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R",
|
||||||
|
UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T",
|
||||||
|
UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V",
|
||||||
|
UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
|
||||||
|
UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
|
||||||
|
UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1",
|
||||||
|
UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
|
||||||
|
UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5",
|
||||||
|
UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7",
|
||||||
|
UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
|
||||||
|
UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return",
|
||||||
|
UInt32(kVK_Tab): "Tab", UInt32(kVK_Escape): "Esc",
|
||||||
|
UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2",
|
||||||
|
UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
|
||||||
|
UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6",
|
||||||
|
UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
|
||||||
|
UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10",
|
||||||
|
UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainAccount: String {
|
||||||
|
case hosted = "remote-api-key"
|
||||||
|
case local = "local-api-key"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainStore {
|
||||||
|
private static let service = "io.github.transui.macos"
|
||||||
|
|
||||||
|
static func read(_ account: KeychainAccount) -> String {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account.rawValue,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var item: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||||
|
let data = item as? Data
|
||||||
|
else { return "" }
|
||||||
|
return String(data: data, encoding: .utf8) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func write(_ value: String, account: KeychainAccount) {
|
||||||
|
let identity: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account.rawValue,
|
||||||
|
]
|
||||||
|
if value.isEmpty {
|
||||||
|
SecItemDelete(identity as CFDictionary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let data = Data(value.utf8)
|
||||||
|
let attributes = [kSecValueData as String: data]
|
||||||
|
if SecItemUpdate(identity as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
|
||||||
|
var item = identity
|
||||||
|
item[kSecValueData as String] = data
|
||||||
|
SecItemAdd(item as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class OverlayPanelController {
|
||||||
|
private let panel: NSPanel
|
||||||
|
|
||||||
|
init(model: AppModel) {
|
||||||
|
panel = NSPanel(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 304, height: 82),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
panel.level = .floating
|
||||||
|
panel.isOpaque = false
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
panel.hasShadow = true
|
||||||
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||||
|
panel.contentView = NSHostingView(rootView: RecordingOverlay(model: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
positionPanel()
|
||||||
|
panel.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
panel.orderOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func positionPanel() {
|
||||||
|
let mouse = NSEvent.mouseLocation
|
||||||
|
let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } ?? NSScreen.main
|
||||||
|
guard let frame = screen?.visibleFrame else { return }
|
||||||
|
let x = frame.midX - panel.frame.width / 2
|
||||||
|
let y = frame.minY + 42
|
||||||
|
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecordingOverlay: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
private let teal = Color(red: 0.039, green: 0.624, blue: 0.604)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
signal
|
||||||
|
.frame(width: 46, height: 34)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
if model.isRecording {
|
||||||
|
Button(action: model.toggleRecording) {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(teal)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.help("Stop and transcribe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 17)
|
||||||
|
.frame(width: 304, height: 82)
|
||||||
|
.background(.ultraThickMaterial, in: RoundedRectangle(cornerRadius: 19, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 19, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var signal: some View {
|
||||||
|
switch model.state {
|
||||||
|
case .recording:
|
||||||
|
HStack(alignment: .center, spacing: 3) {
|
||||||
|
ForEach(0..<5, id: \.self) { index in
|
||||||
|
Capsule()
|
||||||
|
.fill(teal)
|
||||||
|
.frame(width: 4, height: barHeight(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .preparing, .transcribing:
|
||||||
|
ProgressView().controlSize(.small).tint(teal)
|
||||||
|
case .success:
|
||||||
|
Image(systemName: "checkmark.circle.fill").font(.title2).foregroundStyle(teal)
|
||||||
|
case .failure:
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill").font(.title3).foregroundStyle(.red)
|
||||||
|
case .idle:
|
||||||
|
Image(systemName: "waveform").foregroundStyle(teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
switch model.state {
|
||||||
|
case .preparing: "Starting microphone"
|
||||||
|
case .recording: "Recording"
|
||||||
|
case .transcribing: "Transcribing"
|
||||||
|
case .success(let message), .failure(let message): message
|
||||||
|
case .idle: "Ready"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: String {
|
||||||
|
switch model.state {
|
||||||
|
case .recording: Self.duration(model.elapsed)
|
||||||
|
case .preparing: "Waiting for permission"
|
||||||
|
case .transcribing: "Sending audio"
|
||||||
|
case .failure: "Open Transui settings to fix this"
|
||||||
|
case .success: "Done"
|
||||||
|
case .idle: model.settings.shortcut.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func barHeight(_ index: Int) -> CGFloat {
|
||||||
|
let shape = [0.55, 0.82, 1.0, 0.72, 0.45][index]
|
||||||
|
return 7 + 24 * max(0.08, model.level * shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func duration(_ interval: TimeInterval) -> String {
|
||||||
|
let seconds = Int(interval)
|
||||||
|
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
@ObservedObject private var settings: AppSettings
|
||||||
|
@State private var hostedKey = ""
|
||||||
|
@State private var localKey = ""
|
||||||
|
|
||||||
|
init(model: AppModel) {
|
||||||
|
self.model = model
|
||||||
|
settings = model.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Hosted") {
|
||||||
|
TextField(
|
||||||
|
"Endpoint", text: $settings.hostedEndpoint, prompt: Text("https://api.example.com"))
|
||||||
|
TextField("Model", text: $settings.hostedModel)
|
||||||
|
SecureField("API key", text: $hostedKey)
|
||||||
|
.onChange(of: hostedKey) { _, value in settings.hostedKey = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Local fallback") {
|
||||||
|
Toggle("Hosted first, local fallback", isOn: $settings.hostedFirst)
|
||||||
|
TextField("Endpoint", text: $settings.localEndpoint)
|
||||||
|
TextField("Model", text: $settings.localModel)
|
||||||
|
SecureField("API key", text: $localKey)
|
||||||
|
.onChange(of: localKey) { _, value in settings.localKey = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Transcript") {
|
||||||
|
TextField("Prompt", text: $settings.prompt, axis: .vertical)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
Picker("Output", selection: $settings.destination) {
|
||||||
|
ForEach(TranscriptDestination.allCases) { destination in
|
||||||
|
Text(destination.label).tag(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Controls") {
|
||||||
|
LabeledContent("Record shortcut") {
|
||||||
|
HotkeyRecorder(shortcut: $settings.shortcut)
|
||||||
|
.frame(width: 132, height: 24)
|
||||||
|
}
|
||||||
|
if let shortcutError = model.shortcutError {
|
||||||
|
Text(shortcutError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Toggle(
|
||||||
|
"Start at login",
|
||||||
|
isOn: Binding(
|
||||||
|
get: { model.startsAtLogin },
|
||||||
|
set: model.setStartAtLogin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Permissions") {
|
||||||
|
LabeledContent("Microphone") {
|
||||||
|
Button("Open settings", action: model.openMicrophoneSettings)
|
||||||
|
}
|
||||||
|
LabeledContent("Type at cursor") {
|
||||||
|
Button("Open Accessibility", action: model.openAccessibilitySettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(width: 500, height: 620)
|
||||||
|
.onAppear {
|
||||||
|
hostedKey = settings.hostedKey
|
||||||
|
localKey = settings.localKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum TranscriptCleaner {
|
||||||
|
static func clean(_ text: String) -> String {
|
||||||
|
var prefix = text.startIndex
|
||||||
|
if prefix < text.endIndex, text[prefix] == "\u{FEFF}" {
|
||||||
|
prefix = text.index(after: prefix)
|
||||||
|
}
|
||||||
|
while prefix < text.endIndex, text[prefix].isWhitespace {
|
||||||
|
prefix = text.index(after: prefix)
|
||||||
|
}
|
||||||
|
guard text[prefix...].hasPrefix("language ") else { return text }
|
||||||
|
|
||||||
|
let markers = ["<asr_text>", "<astext>"]
|
||||||
|
let marker = markers.compactMap { text.range(of: $0, range: prefix..<text.endIndex) }
|
||||||
|
.min { $0.lowerBound < $1.lowerBound }
|
||||||
|
guard let marker else { return text }
|
||||||
|
return String(text[marker.upperBound...])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
|
||||||
|
enum OutputResult {
|
||||||
|
case typed
|
||||||
|
case copied
|
||||||
|
case copiedBecauseAccessibilityIsOff
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranscriptOutput {
|
||||||
|
@MainActor
|
||||||
|
static func deliver(_ text: String, to destination: TranscriptDestination) -> OutputResult {
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
pasteboard.setString(text, forType: .string)
|
||||||
|
guard destination == .cursor else { return .copied }
|
||||||
|
|
||||||
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
|
||||||
|
guard AXIsProcessTrustedWithOptions(options as CFDictionary) else {
|
||||||
|
return .copiedBecauseAccessibilityIsOff
|
||||||
|
}
|
||||||
|
guard let source = CGEventSource(stateID: .hidSystemState),
|
||||||
|
let keyDown = CGEvent(
|
||||||
|
keyboardEventSource: source,
|
||||||
|
virtualKey: CGKeyCode(kVK_ANSI_V),
|
||||||
|
keyDown: true
|
||||||
|
),
|
||||||
|
let keyUp = CGEvent(
|
||||||
|
keyboardEventSource: source,
|
||||||
|
virtualKey: CGKeyCode(kVK_ANSI_V),
|
||||||
|
keyDown: false
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return .copiedBecauseAccessibilityIsOff
|
||||||
|
}
|
||||||
|
keyDown.flags = .maskCommand
|
||||||
|
keyUp.flags = .maskCommand
|
||||||
|
keyDown.post(tap: .cghidEventTap)
|
||||||
|
keyUp.post(tap: .cghidEventTap)
|
||||||
|
return .typed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum TranscriptionError: LocalizedError {
|
||||||
|
case invalidEndpoint
|
||||||
|
case invalidResponse
|
||||||
|
case http(Int)
|
||||||
|
case allEndpointsFailed([String])
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidEndpoint: "The transcription endpoint is invalid."
|
||||||
|
case .invalidResponse: "The server response did not contain a transcript."
|
||||||
|
case .http(let status): "The server returned HTTP \(status)."
|
||||||
|
case .allEndpointsFailed(let failures): failures.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TranscriptionEndpoint: Equatable {
|
||||||
|
let label: String
|
||||||
|
let endpoint: String
|
||||||
|
let model: String
|
||||||
|
let key: String
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
!endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TranscriptionPlan {
|
||||||
|
let hosted: TranscriptionEndpoint
|
||||||
|
let local: TranscriptionEndpoint
|
||||||
|
let hostedFirst: Bool
|
||||||
|
|
||||||
|
var endpoints: [TranscriptionEndpoint] {
|
||||||
|
var result: [TranscriptionEndpoint] = []
|
||||||
|
if hostedFirst, hosted.isConfigured { result.append(hosted) }
|
||||||
|
if local.isConfigured { result.append(local) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TranscriptionClient {
|
||||||
|
static func endpointURL(from value: String) -> URL? {
|
||||||
|
var endpoint = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
while endpoint.hasSuffix("/") { endpoint.removeLast() }
|
||||||
|
if !endpoint.contains("/v1/audio/transcriptions") {
|
||||||
|
endpoint += "/v1/audio/transcriptions"
|
||||||
|
}
|
||||||
|
guard let url = URL(string: endpoint),
|
||||||
|
let scheme = url.scheme?.lowercased(),
|
||||||
|
scheme == "http" || scheme == "https",
|
||||||
|
url.host != nil
|
||||||
|
else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func transcribe(
|
||||||
|
recording: URL,
|
||||||
|
plan: TranscriptionPlan,
|
||||||
|
prompt: String
|
||||||
|
) async throws -> String {
|
||||||
|
let endpoints = plan.endpoints
|
||||||
|
guard !endpoints.isEmpty else { throw TranscriptionError.invalidEndpoint }
|
||||||
|
|
||||||
|
var failures: [String] = []
|
||||||
|
for endpoint in endpoints {
|
||||||
|
do {
|
||||||
|
return try await transcribe(
|
||||||
|
recording: recording,
|
||||||
|
endpoint: endpoint.endpoint,
|
||||||
|
model: endpoint.model,
|
||||||
|
key: endpoint.key,
|
||||||
|
prompt: prompt
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
failures.append("\(endpoint.label): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw TranscriptionError.allEndpointsFailed(failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transcribe(
|
||||||
|
recording: URL,
|
||||||
|
endpoint: String,
|
||||||
|
model: String,
|
||||||
|
key: String,
|
||||||
|
prompt: String
|
||||||
|
) async throws -> String {
|
||||||
|
guard let url = Self.endpointURL(from: endpoint) else {
|
||||||
|
throw TranscriptionError.invalidEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = "transui-\(UUID().uuidString)"
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 300
|
||||||
|
request.setValue(
|
||||||
|
"multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if !key.isEmpty {
|
||||||
|
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
request.httpBody = try multipartBody(
|
||||||
|
boundary: boundary,
|
||||||
|
recording: recording,
|
||||||
|
model: model,
|
||||||
|
prompt: prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw TranscriptionError.invalidResponse
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw TranscriptionError.http(http.statusCode)
|
||||||
|
}
|
||||||
|
guard data.count <= 1_048_576 else {
|
||||||
|
throw TranscriptionError.invalidResponse
|
||||||
|
}
|
||||||
|
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let text = object["text"] as? String
|
||||||
|
else {
|
||||||
|
throw TranscriptionError.invalidResponse
|
||||||
|
}
|
||||||
|
return TranscriptCleaner.clean(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func multipartBody(
|
||||||
|
boundary: String,
|
||||||
|
recording: URL,
|
||||||
|
model: String,
|
||||||
|
prompt: String
|
||||||
|
) throws -> Data {
|
||||||
|
var data = Data()
|
||||||
|
func field(_ name: String, _ value: String) {
|
||||||
|
data.append("--\(boundary)\r\n")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
|
||||||
|
data.append("\(value)\r\n")
|
||||||
|
}
|
||||||
|
field("response_format", "json")
|
||||||
|
if !model.isEmpty { field("model", model) }
|
||||||
|
if !prompt.isEmpty { field("prompt", prompt) }
|
||||||
|
data.append("--\(boundary)\r\n")
|
||||||
|
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n")
|
||||||
|
data.append("Content-Type: audio/wav\r\n\r\n")
|
||||||
|
data.append(try Data(contentsOf: recording))
|
||||||
|
data.append("\r\n--\(boundary)--\r\n")
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
fileprivate mutating func append(_ string: String) {
|
||||||
|
append(contentsOf: string.utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TransuiApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@StateObject private var model = AppModel()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
MenuBarExtra {
|
||||||
|
Button(model.isRecording ? "Stop and transcribe" : "Start recording") {
|
||||||
|
model.toggleRecording()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("r")
|
||||||
|
.disabled(model.state == .transcribing)
|
||||||
|
|
||||||
|
if model.isRecording {
|
||||||
|
Button("Cancel recording", role: .destructive) {
|
||||||
|
model.cancelRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
SettingsLink { Text("Settings…") }
|
||||||
|
Divider()
|
||||||
|
Button("Quit Transui") { NSApp.terminate(nil) }
|
||||||
|
.keyboardShortcut("q")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: model.isRecording ? "waveform.circle.fill" : "waveform")
|
||||||
|
.accessibilityLabel(model.isRecording ? "Transui recording" : "Transui")
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings {
|
||||||
|
SettingsView(model: model)
|
||||||
|
}
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Transui
|
||||||
|
|
||||||
|
final class TranscriptCleanerTests: XCTestCase {
|
||||||
|
func testRemovesAsTextPrefix() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
TranscriptCleaner.clean("language English<astext>Hello there"),
|
||||||
|
"Hello there"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovesAsrTextPrefix() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
TranscriptCleaner.clean("language zh <asr_text>你好"),
|
||||||
|
"你好"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovesPrefixAfterWhitespaceAndBom() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
TranscriptCleaner.clean("\u{FEFF} \n\tlanguage English<astext>Hello"),
|
||||||
|
"Hello"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreservesOrdinaryTranscript() {
|
||||||
|
XCTAssertEqual(TranscriptCleaner.clean("language is useful"), "language is useful")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Transui
|
||||||
|
|
||||||
|
final class TranscriptionClientTests: XCTestCase {
|
||||||
|
func testAddsTranscriptionPath() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
TranscriptionClient.endpointURL(from: "https://example.com/")?.absoluteString,
|
||||||
|
"https://example.com/v1/audio/transcriptions"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeepsFullTranscriptionPath() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
TranscriptionClient.endpointURL(
|
||||||
|
from: "https://example.com/v1/audio/transcriptions"
|
||||||
|
)?.absoluteString,
|
||||||
|
"https://example.com/v1/audio/transcriptions"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRejectsInvalidEndpoint() {
|
||||||
|
XCTAssertNil(TranscriptionClient.endpointURL(from: ""))
|
||||||
|
XCTAssertNil(TranscriptionClient.endpointURL(from: "example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHostedFirstPlanFallsBackToLocal() {
|
||||||
|
let plan = TranscriptionPlan(
|
||||||
|
hosted: endpoint("Hosted", "https://hosted.example.com"),
|
||||||
|
local: endpoint("Local", "http://localhost:8080"),
|
||||||
|
hostedFirst: true
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(plan.endpoints.map(\.label), ["Hosted", "Local"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisabledHostedFirstUsesOnlyLocal() {
|
||||||
|
let plan = TranscriptionPlan(
|
||||||
|
hosted: endpoint("Hosted", "https://hosted.example.com"),
|
||||||
|
local: endpoint("Local", "http://localhost:8080"),
|
||||||
|
hostedFirst: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(plan.endpoints.map(\.label), ["Local"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPlanSkipsEmptyEndpoints() {
|
||||||
|
let plan = TranscriptionPlan(
|
||||||
|
hosted: endpoint("Hosted", ""),
|
||||||
|
local: endpoint("Local", ""),
|
||||||
|
hostedFirst: true
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(plan.endpoints.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endpoint(_ label: String, _ value: String) -> TranscriptionEndpoint {
|
||||||
|
TranscriptionEndpoint(label: label, endpoint: value, model: "", key: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Transui
|
||||||
|
|
||||||
|
options:
|
||||||
|
createIntermediateGroups: true
|
||||||
|
deploymentTarget:
|
||||||
|
macOS: "15.0"
|
||||||
|
xcodeVersion: "16.0"
|
||||||
|
|
||||||
|
configs:
|
||||||
|
Debug: debug
|
||||||
|
Release: release
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
MARKETING_VERSION: 0.1.0
|
||||||
|
SWIFT_VERSION: 5.0
|
||||||
|
|
||||||
|
targets:
|
||||||
|
Transui:
|
||||||
|
type: application
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "15.0"
|
||||||
|
sources:
|
||||||
|
- path: Sources/Transui
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
CODE_SIGN_ENTITLEMENTS: Transui.entitlements
|
||||||
|
ENABLE_HARDENED_RUNTIME: "YES"
|
||||||
|
GENERATE_INFOPLIST_FILE: "NO"
|
||||||
|
INFOPLIST_FILE: Info.plist
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos
|
||||||
|
PRODUCT_NAME: Transui
|
||||||
|
|
||||||
|
TransuiTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "15.0"
|
||||||
|
sources:
|
||||||
|
- path: Tests/TransuiTests
|
||||||
|
dependencies:
|
||||||
|
- target: Transui
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: "YES"
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos.tests
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
Transui:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
Transui: all
|
||||||
|
TransuiTests: [test]
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
gatherCoverageData: true
|
||||||
|
targets:
|
||||||
|
- TransuiTests
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
analyze:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
@@ -35,7 +35,7 @@ python3 -c 'import json,sys; [print(a["name"]) for a in json.load(sys.stdin).get
|
|||||||
< "$metadata" > "$existing_names"
|
< "$metadata" > "$existing_names"
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
assets=("$artifact_dir"/*.deb)
|
assets=("$artifact_dir"/*.deb "$artifact_dir"/*.apk)
|
||||||
checksums=("$artifact_dir"/SHA256SUMS*)
|
checksums=("$artifact_dir"/SHA256SUMS*)
|
||||||
assets+=("${checksums[@]}")
|
assets+=("${checksums[@]}")
|
||||||
((${#assets[@]} > 0)) || {
|
((${#assets[@]} > 0)) || {
|
||||||
|
|||||||
+38
-12
@@ -15,6 +15,8 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "transcript_cleaner.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char* kAppId = "io.github.transui";
|
constexpr const char* kAppId = "io.github.transui";
|
||||||
@@ -27,7 +29,9 @@ const SecretSchema kSecretSchema = {
|
|||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
std::string hosted_url;
|
std::string hosted_url;
|
||||||
|
std::string hosted_model;
|
||||||
std::string local_url = "http://localhost:8080";
|
std::string local_url = "http://localhost:8080";
|
||||||
|
std::string local_model;
|
||||||
std::string prompt;
|
std::string prompt;
|
||||||
bool hosted_first = true;
|
bool hosted_first = true;
|
||||||
bool clipboard = false;
|
bool clipboard = false;
|
||||||
@@ -52,8 +56,10 @@ struct App {
|
|||||||
|
|
||||||
struct SettingsFields {
|
struct SettingsFields {
|
||||||
GtkWidget* hosted_url;
|
GtkWidget* hosted_url;
|
||||||
|
GtkWidget* hosted_model;
|
||||||
GtkWidget* hosted_key;
|
GtkWidget* hosted_key;
|
||||||
GtkWidget* local_url;
|
GtkWidget* local_url;
|
||||||
|
GtkWidget* local_model;
|
||||||
GtkWidget* local_key;
|
GtkWidget* local_key;
|
||||||
GtkWidget* prompt;
|
GtkWidget* prompt;
|
||||||
GtkWidget* hosted_first;
|
GtkWidget* hosted_first;
|
||||||
@@ -126,8 +132,12 @@ void load_config(App* app) {
|
|||||||
gchar* value = nullptr;
|
gchar* value = nullptr;
|
||||||
value = g_key_file_get_string(file, kConfigGroup, "hosted_url", nullptr);
|
value = g_key_file_get_string(file, kConfigGroup, "hosted_url", nullptr);
|
||||||
if (value) { app->config.hosted_url = value; g_free(value); }
|
if (value) { app->config.hosted_url = value; g_free(value); }
|
||||||
|
value = g_key_file_get_string(file, kConfigGroup, "hosted_model", nullptr);
|
||||||
|
if (value) { app->config.hosted_model = value; g_free(value); }
|
||||||
value = g_key_file_get_string(file, kConfigGroup, "local_url", nullptr);
|
value = g_key_file_get_string(file, kConfigGroup, "local_url", nullptr);
|
||||||
if (value) { app->config.local_url = value; g_free(value); }
|
if (value) { app->config.local_url = value; g_free(value); }
|
||||||
|
value = g_key_file_get_string(file, kConfigGroup, "local_model", nullptr);
|
||||||
|
if (value) { app->config.local_model = value; g_free(value); }
|
||||||
value = g_key_file_get_string(file, kConfigGroup, "prompt", nullptr);
|
value = g_key_file_get_string(file, kConfigGroup, "prompt", nullptr);
|
||||||
if (value) { app->config.prompt = value; g_free(value); }
|
if (value) { app->config.prompt = value; g_free(value); }
|
||||||
if (g_key_file_has_key(file, kConfigGroup, "hosted_first", nullptr))
|
if (g_key_file_has_key(file, kConfigGroup, "hosted_first", nullptr))
|
||||||
@@ -145,7 +155,9 @@ bool save_config(App* app, const Config& config, const std::string& hosted_key,
|
|||||||
const std::string& local_key, std::string* err) {
|
const std::string& local_key, std::string* err) {
|
||||||
GKeyFile* file = g_key_file_new();
|
GKeyFile* file = g_key_file_new();
|
||||||
g_key_file_set_string(file, kConfigGroup, "hosted_url", config.hosted_url.c_str());
|
g_key_file_set_string(file, kConfigGroup, "hosted_url", config.hosted_url.c_str());
|
||||||
|
g_key_file_set_string(file, kConfigGroup, "hosted_model", config.hosted_model.c_str());
|
||||||
g_key_file_set_string(file, kConfigGroup, "local_url", config.local_url.c_str());
|
g_key_file_set_string(file, kConfigGroup, "local_url", config.local_url.c_str());
|
||||||
|
g_key_file_set_string(file, kConfigGroup, "local_model", config.local_model.c_str());
|
||||||
g_key_file_set_string(file, kConfigGroup, "prompt", config.prompt.c_str());
|
g_key_file_set_string(file, kConfigGroup, "prompt", config.prompt.c_str());
|
||||||
g_key_file_set_boolean(file, kConfigGroup, "hosted_first", config.hosted_first);
|
g_key_file_set_boolean(file, kConfigGroup, "hosted_first", config.hosted_first);
|
||||||
g_key_file_set_boolean(file, kConfigGroup, "clipboard", config.clipboard);
|
g_key_file_set_boolean(file, kConfigGroup, "clipboard", config.clipboard);
|
||||||
@@ -190,7 +202,9 @@ void save_clicked(GtkButton*, gpointer data) {
|
|||||||
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(fields->hosted_url), "app"));
|
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(fields->hosted_url), "app"));
|
||||||
Config config;
|
Config config;
|
||||||
config.hosted_url = gtk_entry_get_text(GTK_ENTRY(fields->hosted_url));
|
config.hosted_url = gtk_entry_get_text(GTK_ENTRY(fields->hosted_url));
|
||||||
|
config.hosted_model = gtk_entry_get_text(GTK_ENTRY(fields->hosted_model));
|
||||||
config.local_url = gtk_entry_get_text(GTK_ENTRY(fields->local_url));
|
config.local_url = gtk_entry_get_text(GTK_ENTRY(fields->local_url));
|
||||||
|
config.local_model = gtk_entry_get_text(GTK_ENTRY(fields->local_model));
|
||||||
config.prompt = gtk_entry_get_text(GTK_ENTRY(fields->prompt));
|
config.prompt = gtk_entry_get_text(GTK_ENTRY(fields->prompt));
|
||||||
config.hosted_first = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(fields->hosted_first));
|
config.hosted_first = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(fields->hosted_first));
|
||||||
config.clipboard = gtk_combo_box_get_active(GTK_COMBO_BOX(fields->destination)) == 1;
|
config.clipboard = gtk_combo_box_get_active(GTK_COMBO_BOX(fields->destination)) == 1;
|
||||||
@@ -234,21 +248,25 @@ void open_settings(App* app) {
|
|||||||
|
|
||||||
auto* fields = new SettingsFields();
|
auto* fields = new SettingsFields();
|
||||||
fields->hosted_url = labelled(grid, "Hosted endpoint", gtk_entry_new(), 0);
|
fields->hosted_url = labelled(grid, "Hosted endpoint", gtk_entry_new(), 0);
|
||||||
fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 1);
|
fields->hosted_model = labelled(grid, "Hosted model", gtk_entry_new(), 1);
|
||||||
fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 2);
|
fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 2);
|
||||||
fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 3);
|
fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 3);
|
||||||
fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 4);
|
fields->local_model = labelled(grid, "Local model", gtk_entry_new(), 4);
|
||||||
|
fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 5);
|
||||||
|
fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 6);
|
||||||
fields->hosted_first = gtk_check_button_new_with_label("Hosted first, local fallback");
|
fields->hosted_first = gtk_check_button_new_with_label("Hosted first, local fallback");
|
||||||
gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 5, 1, 1);
|
gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 7, 1, 1);
|
||||||
fields->destination = gtk_combo_box_text_new();
|
fields->destination = gtk_combo_box_text_new();
|
||||||
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Type at cursor");
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Type at cursor");
|
||||||
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Clipboard");
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Clipboard");
|
||||||
labelled(grid, "Output", fields->destination, 6);
|
labelled(grid, "Output", fields->destination, 8);
|
||||||
gtk_entry_set_visibility(GTK_ENTRY(fields->hosted_key), false);
|
gtk_entry_set_visibility(GTK_ENTRY(fields->hosted_key), false);
|
||||||
gtk_entry_set_visibility(GTK_ENTRY(fields->local_key), false);
|
gtk_entry_set_visibility(GTK_ENTRY(fields->local_key), false);
|
||||||
gtk_entry_set_text(GTK_ENTRY(fields->hosted_url), app->config.hosted_url.c_str());
|
gtk_entry_set_text(GTK_ENTRY(fields->hosted_url), app->config.hosted_url.c_str());
|
||||||
|
gtk_entry_set_text(GTK_ENTRY(fields->hosted_model), app->config.hosted_model.c_str());
|
||||||
gtk_entry_set_text(GTK_ENTRY(fields->hosted_key), app->hosted_key.c_str());
|
gtk_entry_set_text(GTK_ENTRY(fields->hosted_key), app->hosted_key.c_str());
|
||||||
gtk_entry_set_text(GTK_ENTRY(fields->local_url), app->config.local_url.c_str());
|
gtk_entry_set_text(GTK_ENTRY(fields->local_url), app->config.local_url.c_str());
|
||||||
|
gtk_entry_set_text(GTK_ENTRY(fields->local_model), app->config.local_model.c_str());
|
||||||
gtk_entry_set_text(GTK_ENTRY(fields->local_key), app->local_key.c_str());
|
gtk_entry_set_text(GTK_ENTRY(fields->local_key), app->local_key.c_str());
|
||||||
gtk_entry_set_text(GTK_ENTRY(fields->prompt), app->config.prompt.c_str());
|
gtk_entry_set_text(GTK_ENTRY(fields->prompt), app->config.prompt.c_str());
|
||||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(fields->hosted_first), app->config.hosted_first);
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(fields->hosted_first), app->config.hosted_first);
|
||||||
@@ -318,7 +336,8 @@ size_t curl_write(char* buffer, size_t size, size_t nmemb, void* data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool transcription_request(const std::string& url, const std::string& key,
|
bool transcription_request(const std::string& url, const std::string& key,
|
||||||
const std::string& path, const std::string& prompt,
|
const std::string& model, const std::string& path,
|
||||||
|
const std::string& prompt,
|
||||||
std::string* transcript, std::string* error) {
|
std::string* transcript, std::string* error) {
|
||||||
CURL* curl = curl_easy_init();
|
CURL* curl = curl_easy_init();
|
||||||
if (!curl) { *error = "Could not initialize HTTP client"; return false; }
|
if (!curl) { *error = "Could not initialize HTTP client"; return false; }
|
||||||
@@ -327,6 +346,11 @@ bool transcription_request(const std::string& url, const std::string& key,
|
|||||||
curl_mimepart* part = curl_mime_addpart(mime);
|
curl_mimepart* part = curl_mime_addpart(mime);
|
||||||
curl_mime_name(part, "response_format");
|
curl_mime_name(part, "response_format");
|
||||||
curl_mime_data(part, "json", CURL_ZERO_TERMINATED);
|
curl_mime_data(part, "json", CURL_ZERO_TERMINATED);
|
||||||
|
if (!model.empty()) {
|
||||||
|
part = curl_mime_addpart(mime);
|
||||||
|
curl_mime_name(part, "model");
|
||||||
|
curl_mime_data(part, model.c_str(), CURL_ZERO_TERMINATED);
|
||||||
|
}
|
||||||
part = curl_mime_addpart(mime);
|
part = curl_mime_addpart(mime);
|
||||||
curl_mime_name(part, "file");
|
curl_mime_name(part, "file");
|
||||||
curl_mime_filedata(part, path.c_str());
|
curl_mime_filedata(part, path.c_str());
|
||||||
@@ -380,7 +404,7 @@ bool transcription_request(const std::string& url, const std::string& key,
|
|||||||
g_object_unref(parser);
|
g_object_unref(parser);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
*transcript = json_object_get_string_member(object, "text");
|
*transcript = clean_transcript(json_object_get_string_member(object, "text"));
|
||||||
g_object_unref(parser);
|
g_object_unref(parser);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -452,15 +476,17 @@ gpointer job_thread(gpointer data) {
|
|||||||
auto* job = static_cast<Job*>(data);
|
auto* job = static_cast<Job*>(data);
|
||||||
std::string hosted_error;
|
std::string hosted_error;
|
||||||
if (job->config.hosted_first && !job->config.hosted_url.empty()) {
|
if (job->config.hosted_first && !job->config.hosted_url.empty()) {
|
||||||
job->ok = transcription_request(job->config.hosted_url, job->hosted_key, job->file,
|
job->ok = transcription_request(job->config.hosted_url, job->hosted_key,
|
||||||
job->config.prompt, &job->text, &hosted_error);
|
job->config.hosted_model, job->file, job->config.prompt,
|
||||||
|
&job->text, &hosted_error);
|
||||||
if (job->ok) {
|
if (job->ok) {
|
||||||
g_main_context_invoke(nullptr, finish_job, job);
|
g_main_context_invoke(nullptr, finish_job, job);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
job->ok = transcription_request(job->config.local_url, job->local_key, job->file,
|
job->ok = transcription_request(job->config.local_url, job->local_key,
|
||||||
job->config.prompt, &job->text, &job->error);
|
job->config.local_model, job->file, job->config.prompt,
|
||||||
|
&job->text, &job->error);
|
||||||
if (!job->ok && !hosted_error.empty()) job->error = "Hosted: " + hosted_error + "\nLocal: " + job->error;
|
if (!job->ok && !hosted_error.empty()) job->error = "Hosted: " + hosted_error + "\nLocal: " + job->error;
|
||||||
g_main_context_invoke(nullptr, finish_job, job);
|
g_main_context_invoke(nullptr, finish_job, job);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "transcript_cleaner.h"
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
std::string clean_transcript(std::string transcript) {
|
||||||
|
// Qwen3-ASR prefixes raw transcripts with the detected language and a text marker.
|
||||||
|
constexpr std::string_view kLanguagePrefix = "language ";
|
||||||
|
constexpr std::string_view kMarkers[] = {"<asr_text>", "<astext>"};
|
||||||
|
|
||||||
|
size_t prefix = 0;
|
||||||
|
if (transcript.compare(0, 3, "\xef\xbb\xbf") == 0) prefix = 3;
|
||||||
|
while (prefix < transcript.size() &&
|
||||||
|
std::isspace(static_cast<unsigned char>(transcript[prefix]))) {
|
||||||
|
prefix++;
|
||||||
|
}
|
||||||
|
if (transcript.compare(prefix, kLanguagePrefix.size(), kLanguagePrefix) != 0) {
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t marker_position = std::string::npos;
|
||||||
|
std::string_view matched_marker;
|
||||||
|
for (std::string_view marker : kMarkers) {
|
||||||
|
const size_t position = transcript.find(marker, prefix + kLanguagePrefix.size());
|
||||||
|
if (position != std::string::npos &&
|
||||||
|
(marker_position == std::string::npos || position < marker_position)) {
|
||||||
|
marker_position = position;
|
||||||
|
matched_marker = marker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (marker_position == std::string::npos) return transcript;
|
||||||
|
return transcript.substr(marker_position + matched_marker.size());
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
std::string clean_transcript(std::string transcript);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#include "transcript_cleaner.h"
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
assert(clean_transcript("language English<astext>What do you think?") ==
|
||||||
|
"What do you think?");
|
||||||
|
assert(clean_transcript("language Chinese<astext>\xe6\x9d\xa5\xe8\xae\xa9\xe4\xbd\xa0\xe5\x8f\x91\xe7\x8e\xb0\xe6\xa0\x87\xe7\xad\xbe\xe3\x80\x82") ==
|
||||||
|
"\xe6\x9d\xa5\xe8\xae\xa9\xe4\xbd\xa0\xe5\x8f\x91\xe7\x8e\xb0\xe6\xa0\x87\xe7\xad\xbe\xe3\x80\x82");
|
||||||
|
assert(clean_transcript("language German<asr_text>Ich habe keine Idee.") ==
|
||||||
|
"Ich habe keine Idee.");
|
||||||
|
assert(clean_transcript("language Japanese<asr_text>\xe3\x81\x82\xe3\x82\x8a\xe3\x81\x8c\xe3\x81\xa8\xe3\x81\x86\xe3\x81\x94\xe3\x81\x96\xe3\x81\x84\xe3\x81\xbe\xe3\x81\x99\xe3\x80\x82") ==
|
||||||
|
"\xe3\x81\x82\xe3\x82\x8a\xe3\x81\x8c\xe3\x81\xa8\xe3\x81\x86\xe3\x81\x94\xe3\x81\x96\xe3\x81\x84\xe3\x81\xbe\xe3\x81\x99\xe3\x80\x82");
|
||||||
|
assert(clean_transcript(" \n\tlanguage English<asr_text>Hello") == "Hello");
|
||||||
|
assert(clean_transcript("Language matters.") == "Language matters.");
|
||||||
|
assert(clean_transcript("language English") == "language English");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user