1 parent
2b89bbd959
commit
066c7d758e
23 files changed
+867
-29
No files matched your search
+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")
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ android {
|
|||||||
applicationId 'io.github.transui.android'
|
applicationId 'io.github.transui.android'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName '0.1.0'
|
versionName '0.1.1'
|
||||||
|
|
||||||
testInstrumentationRunner 'android.test.InstrumentationTestRunner'
|
testInstrumentationRunner 'android.test.InstrumentationTestRunner'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".TransuiRecognitionService"
|
android:name=".TransuiRecognitionService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -35,5 +46,18 @@
|
|||||||
android:name="android.speech"
|
android:name="android.speech"
|
||||||
android:resource="@xml/recognition_service" />
|
android:resource="@xml/recognition_service" />
|
||||||
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -5,16 +5,19 @@ final class AppConfig {
|
|||||||
final String model;
|
final String model;
|
||||||
final String apiKey;
|
final String apiKey;
|
||||||
final String prompt;
|
final String prompt;
|
||||||
|
final boolean pushToTalk;
|
||||||
|
|
||||||
AppConfig(
|
AppConfig(
|
||||||
String endpoint,
|
String endpoint,
|
||||||
String model,
|
String model,
|
||||||
String apiKey,
|
String apiKey,
|
||||||
String prompt) {
|
String prompt,
|
||||||
|
boolean pushToTalk) {
|
||||||
this.endpoint = endpoint;
|
this.endpoint = endpoint;
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.prompt = prompt;
|
this.prompt = prompt;
|
||||||
|
this.pushToTalk = pushToTalk;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasUsableEndpoint() {
|
boolean hasUsableEndpoint() {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ final class ConfigRepository {
|
|||||||
preferences.getString("remote_endpoint", ""),
|
preferences.getString("remote_endpoint", ""),
|
||||||
preferences.getString("remote_model", ""),
|
preferences.getString("remote_model", ""),
|
||||||
decryptOrEmpty(preferences.getString("remote_key", "")),
|
decryptOrEmpty(preferences.getString("remote_key", "")),
|
||||||
preferences.getString("prompt", ""));
|
preferences.getString("prompt", ""),
|
||||||
|
preferences.getBoolean("push_to_talk", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
void save(AppConfig config) throws GeneralSecurityException, IOException {
|
void save(AppConfig config) throws GeneralSecurityException, IOException {
|
||||||
@@ -29,6 +30,7 @@ final class ConfigRepository {
|
|||||||
.putString("remote_model", config.model)
|
.putString("remote_model", config.model)
|
||||||
.putString("remote_key", SecretStore.encrypt(config.apiKey))
|
.putString("remote_key", SecretStore.encrypt(config.apiKey))
|
||||||
.putString("prompt", config.prompt)
|
.putString("prompt", config.prompt)
|
||||||
|
.putBoolean("push_to_talk", config.pushToTalk)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.os.Bundle;
|
|||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
|
import android.widget.Switch;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ public final class MainActivity extends Activity {
|
|||||||
private EditText model;
|
private EditText model;
|
||||||
private EditText apiKey;
|
private EditText apiKey;
|
||||||
private EditText prompt;
|
private EditText prompt;
|
||||||
|
private Switch pushToTalk;
|
||||||
private TextView providerStatus;
|
private TextView providerStatus;
|
||||||
private Button microphone;
|
private Button microphone;
|
||||||
private ConfigRepository repository;
|
private ConfigRepository repository;
|
||||||
@@ -59,6 +61,7 @@ public final class MainActivity extends Activity {
|
|||||||
model = findViewById(R.id.model);
|
model = findViewById(R.id.model);
|
||||||
apiKey = findViewById(R.id.api_key);
|
apiKey = findViewById(R.id.api_key);
|
||||||
prompt = findViewById(R.id.prompt);
|
prompt = findViewById(R.id.prompt);
|
||||||
|
pushToTalk = findViewById(R.id.push_to_talk);
|
||||||
providerStatus = findViewById(R.id.provider_status);
|
providerStatus = findViewById(R.id.provider_status);
|
||||||
microphone = findViewById(R.id.microphone);
|
microphone = findViewById(R.id.microphone);
|
||||||
}
|
}
|
||||||
@@ -68,6 +71,7 @@ public final class MainActivity extends Activity {
|
|||||||
model.setText(config.model);
|
model.setText(config.model);
|
||||||
apiKey.setText(config.apiKey);
|
apiKey.setText(config.apiKey);
|
||||||
prompt.setText(config.prompt);
|
prompt.setText(config.prompt);
|
||||||
|
pushToTalk.setChecked(config.pushToTalk);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean saveConfig() {
|
private boolean saveConfig() {
|
||||||
@@ -75,7 +79,8 @@ public final class MainActivity extends Activity {
|
|||||||
value(endpoint).trim(),
|
value(endpoint).trim(),
|
||||||
value(model).trim(),
|
value(model).trim(),
|
||||||
value(apiKey).trim(),
|
value(apiKey).trim(),
|
||||||
value(prompt));
|
value(prompt),
|
||||||
|
pushToTalk.isChecked());
|
||||||
try {
|
try {
|
||||||
repository.save(config);
|
repository.save(config);
|
||||||
Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package io.github.transui.android;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.widget.Button;
|
||||||
|
|
||||||
|
public final class PushToTalkButton extends Button {
|
||||||
|
private Runnable pressedAction;
|
||||||
|
private Runnable releasedAction;
|
||||||
|
|
||||||
|
public PushToTalkButton(Context context, AttributeSet attributes) {
|
||||||
|
super(context, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
|
||||||
|
this.pressedAction = pressedAction;
|
||||||
|
this.releasedAction = releasedAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
if (pressedAction == null || releasedAction == null) return super.onTouchEvent(event);
|
||||||
|
if (event.getAction() == MotionEvent.ACTION_DOWN) {
|
||||||
|
pressedAction.run();
|
||||||
|
setPressed(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||||
|
releasedAction.run();
|
||||||
|
setPressed(false);
|
||||||
|
super.performClick();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.getAction() == MotionEvent.ACTION_CANCEL) {
|
||||||
|
releasedAction.run();
|
||||||
|
setPressed(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performClick() {
|
||||||
|
if (pressedAction != null && releasedAction != null) {
|
||||||
|
pressedAction.run();
|
||||||
|
releasedAction.run();
|
||||||
|
}
|
||||||
|
return super.performClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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.widget.Button;
|
||||||
|
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 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);
|
||||||
|
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() {
|
||||||
|
if (config.pushToTalk) {
|
||||||
|
record.setText(R.string.hold_to_talk);
|
||||||
|
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
||||||
|
} else {
|
||||||
|
record.setText(R.string.stop);
|
||||||
|
record.setOnClickListener(view -> stopRecording());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
controller.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showUnavailable(int message) {
|
||||||
|
status.setText(message);
|
||||||
|
elapsed.setText("");
|
||||||
|
record.setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
recordingStartedAt = SystemClock.elapsedRealtime();
|
||||||
|
elapsed.setText(R.string.elapsed_zero);
|
||||||
|
handler.post(updateElapsed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTranscribing() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
handler.removeCallbacks(updateElapsed);
|
||||||
|
status.setText(R.string.transcribing);
|
||||||
|
record.setEnabled(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,29 @@ package io.github.transui.android;
|
|||||||
|
|
||||||
final class TranscriptCleaner {
|
final class TranscriptCleaner {
|
||||||
private static final String QWEN_LANGUAGE_PREFIX = "language ";
|
private static final String QWEN_LANGUAGE_PREFIX = "language ";
|
||||||
private static final String QWEN_TEXT_MARKER = "<astext>";
|
private static final String[] QWEN_TEXT_MARKERS = {"<asr_text>", "<astext>"};
|
||||||
|
|
||||||
private TranscriptCleaner() {}
|
private TranscriptCleaner() {}
|
||||||
|
|
||||||
static String clean(String transcript) {
|
static String clean(String transcript) {
|
||||||
// Qwen3-ASR prefixes transcripts with the detected language and this marker.
|
// Qwen3-ASR prefixes raw transcripts with the detected language and a text marker.
|
||||||
if (!transcript.startsWith(QWEN_LANGUAGE_PREFIX)) return transcript;
|
int prefix = 0;
|
||||||
int marker = transcript.indexOf(QWEN_TEXT_MARKER, QWEN_LANGUAGE_PREFIX.length());
|
if (!transcript.isEmpty() && transcript.charAt(0) == '\ufeff') prefix++;
|
||||||
if (marker < 0) return transcript;
|
while (prefix < transcript.length() && Character.isWhitespace(transcript.charAt(prefix))) {
|
||||||
return transcript.substring(marker + QWEN_TEXT_MARKER.length());
|
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,146 @@
|
|||||||
|
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.inputmethod.InputConnection;
|
||||||
|
import android.widget.Button;
|
||||||
|
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 PushToTalkButton record;
|
||||||
|
private AppConfig config;
|
||||||
|
private VoiceRecordingController controller;
|
||||||
|
private boolean sessionActive;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
record = view.findViewById(R.id.voice_record);
|
||||||
|
view.findViewById(R.id.voice_keyboard).setOnClickListener(button -> returnToKeyboard());
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.setOnClickListener(null);
|
||||||
|
record.setPushToTalkActions(null, null);
|
||||||
|
if (config.pushToTalk) {
|
||||||
|
record.setText(R.string.hold_to_talk);
|
||||||
|
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
||||||
|
} else {
|
||||||
|
record.setText(R.string.stop);
|
||||||
|
record.setOnClickListener(view -> stopRecording());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
controller.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelSession() {
|
||||||
|
if (controller != null) controller.cancel();
|
||||||
|
controller = null;
|
||||||
|
sessionActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showUnavailable(int message) {
|
||||||
|
status.setText(message);
|
||||||
|
record.setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTranscribing() {
|
||||||
|
handler.post(() -> {
|
||||||
|
status.setText(R.string.transcribing);
|
||||||
|
record.setEnabled(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,158 @@
|
|||||||
|
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 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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
short[] samples = new short[bufferBytes / 2];
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,14 @@
|
|||||||
android:maxLines="3"
|
android:maxLines="3"
|
||||||
android:singleLine="false" />
|
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
|
<Button
|
||||||
android:id="@+id/save"
|
android:id="@+id/save"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?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.PushToTalkButton
|
||||||
|
android:id="@+id/recognition_record"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:text="@string/stop" />
|
||||||
|
|
||||||
|
<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,59 @@
|
|||||||
|
<?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:orientation="vertical"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingTop="18dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingBottom="18dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
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="14dp"
|
||||||
|
android:paddingEnd="14dp"
|
||||||
|
android:text="@string/listening"
|
||||||
|
android:textColor="@color/ink"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
style="?android:attr/buttonBarStyle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/voice_keyboard"
|
||||||
|
style="?android:attr/buttonBarButtonStyle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/keyboard" />
|
||||||
|
|
||||||
|
<io.github.transui.android.PushToTalkButton
|
||||||
|
android:id="@+id/voice_record"
|
||||||
|
style="?android:attr/buttonBarButtonStyle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/stop" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -8,4 +8,13 @@
|
|||||||
<item name="android:colorAccent">@color/signal</item>
|
<item name="android:colorAccent">@color/signal</item>
|
||||||
<item name="android:windowBackground">@color/mist</item>
|
<item name="android:windowBackground">@color/mist</item>
|
||||||
</style>
|
</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>
|
</resources>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<string name="model">Model</string>
|
<string name="model">Model</string>
|
||||||
<string name="api_key">API key</string>
|
<string name="api_key">API key</string>
|
||||||
<string name="prompt">Prompt</string>
|
<string name="prompt">Prompt</string>
|
||||||
|
<string name="push_to_talk">Push to talk</string>
|
||||||
<string name="save">Save settings</string>
|
<string name="save">Save settings</string>
|
||||||
<string name="open_language_settings">Open language settings</string>
|
<string name="open_language_settings">Open language settings</string>
|
||||||
<string name="allow_microphone">Allow microphone</string>
|
<string name="allow_microphone">Allow microphone</string>
|
||||||
@@ -18,4 +19,15 @@
|
|||||||
<string name="settings_saved">Settings saved</string>
|
<string name="settings_saved">Settings saved</string>
|
||||||
<string name="settings_save_failed">Could not save API keys</string>
|
<string name="settings_save_failed">Could not save API keys</string>
|
||||||
<string name="language_settings_unavailable">Language settings are unavailable</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>
|
</resources>
|
||||||
@@ -28,4 +28,13 @@
|
|||||||
<item name="android:textStyle">bold</item>
|
<item name="android:textStyle">bold</item>
|
||||||
<item name="android:letterSpacing">0.12</item>
|
<item name="android:letterSpacing">0.12</item>
|
||||||
</style>
|
</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>
|
</resources>
|
||||||
@@ -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>
|
||||||
@@ -6,10 +6,20 @@ import org.junit.Test;
|
|||||||
|
|
||||||
public final class TranscriptCleanerTest {
|
public final class TranscriptCleanerTest {
|
||||||
@Test
|
@Test
|
||||||
public void removesQwenLanguagePrefix() {
|
public void removesObservedQwenLanguagePrefixes() {
|
||||||
assertEquals(
|
assertEquals("What do you think?", TranscriptCleaner.clean(
|
||||||
"What do you think?",
|
"language English<astext>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
|
@Test
|
||||||
|
|||||||
+2
-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";
|
||||||
@@ -333,18 +335,6 @@ size_t curl_write(char* buffer, size_t size, size_t nmemb, void* data) {
|
|||||||
return size * nmemb;
|
return size * nmemb;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string clean_transcript(std::string transcript) {
|
|
||||||
// Qwen3-ASR prefixes transcripts with the detected language and this marker.
|
|
||||||
constexpr const char* kLanguagePrefix = "language ";
|
|
||||||
constexpr const char* kTextMarker = "<astext>";
|
|
||||||
if (transcript.compare(0, std::strlen(kLanguagePrefix), kLanguagePrefix) != 0) {
|
|
||||||
return transcript;
|
|
||||||
}
|
|
||||||
const size_t marker = transcript.find(kTextMarker, std::strlen(kLanguagePrefix));
|
|
||||||
if (marker == std::string::npos) return transcript;
|
|
||||||
return transcript.substr(marker + std::strlen(kTextMarker));
|
|
||||||
}
|
|
||||||
|
|
||||||
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& model, const std::string& path,
|
const std::string& model, const std::string& path,
|
||||||
const std::string& prompt,
|
const std::string& prompt,
|
||||||
|
|||||||
@@ -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