diff --git a/android/app/build.gradle b/android/app/build.gradle
index ef97c8f..1b109bd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -59,6 +59,17 @@ android {
buildConfigField "String", "COMMIT", "\"" + ("git rev-parse HEAD\n".execute().getText().trim() ?: (System.getenv('CURRENT_COMMIT') ?: "NO_COMMIT")) + "\""
+ externalNativeBuild {
+ cmake {
+ cppFlags "-std=c++11"
+ arguments "-DANDROID_STL=c++_shared"
+ }
+ }
+
+ ndk {
+ abiFilters 'arm64-v8a'
+ }
+
}
signingConfigs {
release {
@@ -100,6 +111,12 @@ android {
//checkReleaseBuilds false
abortOnError false
}
+ externalNativeBuild {
+ cmake {
+ path "src/main/cpp/CMakeLists.txt"
+ version "3.22.1"
+ }
+ }
packagingOptions {
pickFirst 'lib/arm64-v8a/libc++_shared.so'
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 281c3fd..2d1a35e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,7 +4,7 @@
-
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOG_TAG "NativeAudio"
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
+
+int server_fd = -1;
+int client_fd = -1;
+
+extern "C" JNIEXPORT jint JNICALL
+Java_com_example_tiny_1computer_AudioStream_nativeInit(JNIEnv *env, jobject thiz, jstring path) {
+ const char *socket_path = env->GetStringUTFChars(path, 0);
+
+ server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (server_fd == -1) {
+ LOGE("Socket creation failed");
+ return -1;
+ }
+
+ struct sockaddr_un addr;
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1);
+ unlink(socket_path); // Remove existing file if any
+
+ if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
+ LOGE("Bind failed: %s", strerror(errno));
+ close(server_fd);
+ return -1;
+ }
+
+ if (listen(server_fd, 1) == -1) {
+ LOGE("Listen failed");
+ close(server_fd);
+ return -1;
+ }
+
+ env->ReleaseStringUTFChars(path, socket_path);
+ return 0;
+}
+
+extern "C" JNIEXPORT jint JNICALL
+Java_com_example_tiny_1computer_AudioStream_nativeAccept(JNIEnv *env, jobject thiz) {
+ if (server_fd == -1) return -1;
+ // Blocks here until Linux connects
+ client_fd = accept(server_fd, NULL, NULL);
+ if (client_fd == -1) {
+ LOGE("Accept failed: %s", strerror(errno));
+ return -1;
+ }
+ return 0;
+}
+
+extern "C" JNIEXPORT jint JNICALL
+Java_com_example_tiny_1computer_AudioStream_nativeSend(JNIEnv *env, jobject thiz, jbyteArray data, jint size) {
+ if (client_fd == -1) return -1;
+
+ jbyte *buffer = env->GetByteArrayElements(data, NULL);
+ ssize_t sent = write(client_fd, buffer, size);
+ env->ReleaseByteArrayElements(data, buffer, JNI_ABORT);
+
+ if (sent == -1 && errno != EAGAIN) {
+ LOGE("Write failed (Broken Pipe?)");
+ return -1;
+ }
+ return sent;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_example_tiny_1computer_AudioStream_nativeClose(JNIEnv *env, jobject thiz) {
+ if (client_fd != -1) { close(client_fd); client_fd = -1; }
+ if (server_fd != -1) { close(server_fd); server_fd = -1; }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/example/tiny_computer/AudioStream.kt b/android/app/src/main/kotlin/com/example/tiny_computer/AudioStream.kt
new file mode 100644
index 0000000..92d4b93
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/tiny_computer/AudioStream.kt
@@ -0,0 +1,99 @@
+package com.example.tiny_computer
+
+import android.annotation.SuppressLint
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.MediaRecorder
+import android.util.Log
+
+object AudioStream {
+ init {
+ System.loadLibrary("native-socket")
+ }
+
+ private var isStreaming = false
+ private var recordingThread: Thread? = null
+
+ // Native functions
+ private external fun nativeInit(path: String): Int
+ private external fun nativeAccept(): Int
+ private external fun nativeSend(data: ByteArray, size: Int): Int
+ private external fun nativeClose()
+
+ @SuppressLint("MissingPermission") // Ensure RECORD_AUDIO is granted in Manifest
+ fun startStreaming(path: String) {
+ if (isStreaming) return
+ isStreaming = true
+
+ recordingThread = Thread {
+ // 1. Initialize Socket Server
+ if (nativeInit(path) < 0) {
+ Log.e("AudioStream", "Failed to bind socket")
+ isStreaming = false
+ return@Thread
+ }
+
+ // 2. Wait for Linux client to connect (Blocking)
+ Log.d("AudioStream", "Waiting for connection on $path...")
+ if (nativeAccept() < 0) {
+ Log.e("AudioStream", "Accept failed")
+ isStreaming = false
+ return@Thread
+ }
+ Log.d("AudioStream", "Client connected!")
+
+ // 3. Setup AudioRecord
+ val sampleRate = 44100
+ val bufferSize = AudioRecord.getMinBufferSize(
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT
+ )
+
+ val recorder = AudioRecord(
+ MediaRecorder.AudioSource.MIC,
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufferSize
+ )
+
+ val data = ByteArray(bufferSize)
+ recorder.startRecording()
+
+ val discardMillis = 5000 // 丢弃前5秒
+ val discardBytes = (sampleRate * 2 * discardMillis / 1000).toInt() // 16bit = 2字节
+ var bytesRead = 0
+
+ // 先读取并丢弃初始数据
+ while (bytesRead < discardBytes && isStreaming) {
+ val readBytes = recorder.read(data, 0, minOf(bufferSize, discardBytes - bytesRead))
+ if (readBytes > 0) {
+ bytesRead += readBytes
+ }
+ }
+
+ // 4. Streaming Loop
+ while (isStreaming) {
+ val readBytes = recorder.read(data, 0, bufferSize)
+ if (readBytes > 0) {
+ val sent = nativeSend(data, readBytes)
+ if (sent < 0) break // Socket broken
+ }
+ }
+
+ // Cleanup
+ recorder.stop()
+ recorder.release()
+ nativeClose()
+ }
+ recordingThread?.start()
+ }
+
+ fun stopStreaming() {
+ isStreaming = false
+ nativeClose() // Unblocks the native Accept/Send if hung
+ recordingThread?.join()
+ recordingThread = null
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/example/tiny_computer/MainActivity.kt b/android/app/src/main/kotlin/com/example/tiny_computer/MainActivity.kt
index 27c18ee..9fc3c85 100644
--- a/android/app/src/main/kotlin/com/example/tiny_computer/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/example/tiny_computer/MainActivity.kt
@@ -27,6 +27,12 @@ class MainActivity: FlutterActivity() {
"getNativeLibraryPath" -> {
result.success(getApplicationInfo().nativeLibraryDir)
}
+ "startStreaming" -> {
+ AudioStream.startStreaming(call.argument("path")!!)
+ }
+ "stopStreaming" -> {
+ AudioStream.stopStreaming()
+ }
else -> {
// 不支持的方法名
result.notImplemented()
diff --git a/extra/tiny_virtual_mic.c b/extra/tiny_virtual_mic.c
new file mode 100644
index 0000000..b9ca7d7
--- /dev/null
+++ b/extra/tiny_virtual_mic.c
@@ -0,0 +1,74 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define BUFSIZE 4096
+
+int main(int argc, char *argv[]) {
+ if (argc != 3) {
+ fprintf(stderr, "Usage: %s \n", argv[0]);
+ return 1;
+ }
+
+ // 1. Setup PulseAudio
+ static const pa_sample_spec ss = {
+ .format = PA_SAMPLE_S16LE,
+ .rate = 44100,
+ .channels = 1
+ };
+
+ pa_buffer_attr attr;
+ attr.maxlength = (uint32_t) -1;
+ attr.tlength = pa_usec_to_bytes(60000, &ss); // 目标延迟设为 60ms
+ attr.prebuf = (uint32_t) -1;
+ attr.minreq = (uint32_t) -1;
+ attr.fragsize = (uint32_t) -1;
+
+ int error;
+ pa_simple *s = pa_simple_new(NULL, "AndroidStream", PA_STREAM_PLAYBACK, argv[2], "live_audio", &ss, NULL, &attr, &error);
+ if (!s) {
+ fprintf(stderr, "pa_simple_new() failed: %s\n", pa_strerror(error));
+ return 1;
+ }
+
+ // 2. Connect to Android Socket
+ int sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (sock_fd < 0) {
+ perror("socket");
+ return 1;
+ }
+
+ struct sockaddr_un addr;
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, argv[1], sizeof(addr.sun_path) - 1);
+
+ printf("Connecting to %s...\n", argv[1]);
+ while (connect(sock_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
+ printf("Waiting for server...\n");
+ sleep(1); // Retry logic since Android might start later
+ }
+ printf("Connected! Playing audio...\n");
+
+ // 3. Stream Loop
+ uint8_t buf[BUFSIZE];
+ while (1) {
+ ssize_t r = read(sock_fd, buf, sizeof(buf));
+ if (r <= 0) break;
+
+ if (pa_simple_write(s, buf, (size_t)r, &error) < 0) {
+ fprintf(stderr, "pa_simple_write() failed: %s\n", pa_strerror(error));
+ break;
+ }
+ }
+
+ // Cleanup
+ pa_simple_free(s);
+ close(sock_fd);
+ return 0;
+}
\ No newline at end of file
diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb
index dd12232..5ae591b 100644
--- a/lib/l10n/intl_en.arb
+++ b/lib/l10n/intl_en.arb
@@ -109,5 +109,7 @@
"updateRequest": "Please try to use the latest version. Visit the project address to check for the latest version.",
"avncScreenResize": "Adaptive Screen Size",
"avncResizeFactor": "Screen Scaling Ratio",
- "avncResizeFactorValue": "Current scaling is"
+ "avncResizeFactorValue": "Current scaling is",
+ "microphoneSupport": "Microphone Support",
+ "startStreaming": "Start Microphone"
}
\ No newline at end of file
diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb
index 81a0981..ec76bda 100644
--- a/lib/l10n/intl_zh.arb
+++ b/lib/l10n/intl_zh.arb
@@ -109,5 +109,7 @@
"updateRequest": "请尽量使用最新版本。前往项目地址可查看最新版本。",
"avncScreenResize": "自适应屏幕尺寸",
"avncResizeFactor": "屏幕缩放比",
- "avncResizeFactorValue": "当前缩放为"
+ "avncResizeFactorValue": "当前缩放为",
+ "microphoneSupport": "麦克风支持",
+ "startStreaming": "开启麦克风"
}
\ No newline at end of file
diff --git a/lib/l10n/intl_zh_Hant.arb b/lib/l10n/intl_zh_Hant.arb
index 5cfe98e..748e4c2 100644
--- a/lib/l10n/intl_zh_Hant.arb
+++ b/lib/l10n/intl_zh_Hant.arb
@@ -109,5 +109,7 @@
"updateRequest": "請盡量使用最新版本。前往專案網址查看最新版本。",
"avncScreenResize": "自適應螢幕尺寸",
"avncResizeFactor": "螢幕縮放比",
- "avncResizeFactorValue": "目前縮放為"
+ "avncResizeFactorValue": "目前縮放為",
+ "microphoneSupport": "麥克風支援",
+ "startStreaming": "開啟麥克風"
}
diff --git a/lib/main.dart b/lib/main.dart
index 3f99d2d..98242be 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -170,7 +170,7 @@ class SettingPage extends StatefulWidget {
class _SettingPageState extends State {
- final List _expandState = [false, false, false, false, false, false];
+ final List _expandState = [false, false, false, false, false, false, false];
double _avncScaleFactor = Util.getGlobal("avncScaleFactor") as double;
@@ -602,6 +602,37 @@ sed -i -E "s@^(VNC_RESOLUTION)=.*@\\1=${w}x${h}@" \$(command -v startvnc)""");
setState(() {});
},),
],))),
+ ExpansionPanel(
+ isExpanded: _expandState[6],
+ headerBuilder: ((context, isExpanded) {
+ return ListTile(title: Text(AppLocalizations.of(context)!.microphoneSupport), subtitle: Text(AppLocalizations.of(context)!.experimentalFeature));
+ }), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
+ const SizedBox.square(dimension: 16),
+ SwitchListTile(title: Text(AppLocalizations.of(context)!.startStreaming), value: G.isStreaming, onChanged:(value) async {
+ if (value) {
+ await Permission.microphone.request();
+ if (await Permission.microphone.isGranted) {
+ String path = "/tmp/android_audio";
+ D.androidChannel.invokeMethod("startStreaming", {"path": "${G.dataPath}/containers/${G.currentContainer}$path"});
+ Util.termWrite("""
+pactl load-module module-null-sink sink_name=AndroidSink sink_properties=device.description="Android_Audio_Stream"
+pactl load-module module-remap-source master=AndroidSink.monitor source_name=AndroidMic source_properties=device.description="Android_Virtual_Mic"
+pkill -f tiny_virtual_mic
+tiny_virtual_mic $path AndroidSink &""");
+ G.pageIndex.value = 0;
+ }
+ } else {
+ Util.termWrite("""
+pactl list short modules | grep "Android" | cut -f1 | xargs -L1 pactl unload-module
+pkill -f tiny_virtual_mic""");
+ G.pageIndex.value = 0;
+ D.androidChannel.invokeMethod("stopStreaming", {});
+ }
+ G.isStreaming = value;
+ setState(() {});
+ },),
+ const SizedBox.square(dimension: 16),
+ ],))),
],);
}
}
diff --git a/lib/workflow.dart b/lib/workflow.dart
index 43a6b7e..3dd0930 100644
--- a/lib/workflow.dart
+++ b/lib/workflow.dart
@@ -123,7 +123,6 @@ class Util {
case "avncResizeDesktop" : return b ? G.prefs.getBool(key)! : (value){G.prefs.setBool(key, value); return value;}(true);
case "avncScaleFactor" : return b ? G.prefs.getDouble(key)!.clamp(-1.0, 1.0) : (value){G.prefs.setDouble(key, value); return value;}(-0.5);
case "useX11" : return b ? G.prefs.getBool(key)! : (value){G.prefs.setBool(key, value); return value;}(false);
- case "defaultFFmpegCommand" : return b ? G.prefs.getString(key)! : (value){G.prefs.setString(key, value); return value;}("-hide_banner -an -max_delay 1000000 -r 30 -f android_camera -camera_index 0 -i 0:0 -vf scale=iw/2:-1 -rtsp_transport udp -f rtsp rtsp://127.0.0.1:8554/stream");
case "defaultVirglCommand" : return b ? G.prefs.getString(key)! : (value){G.prefs.setString(key, value); return value;}("--use-egl-surfaceless --use-gles --socket-path=\$CONTAINER_DIR/tmp/.virgl_test");
case "defaultVirglOpt" : return b ? G.prefs.getString(key)! : (value){G.prefs.setString(key, value); return value;}("GALLIUM_DRIVER=virpipe");
case "defaultTurnipOpt" : return b ? G.prefs.getString(key)! : (value){G.prefs.setString(key, value); return value;}("MESA_LOADER_DRIVER_OVERRIDE=zink VK_ICD_FILENAMES=/home/tiny/.local/share/tiny/extra/freedreno_icd.aarch64.json TU_DEBUG=noconform");
@@ -518,11 +517,7 @@ class G {
static late VirtualKeyboard keyboard; //存储ctrl, shift, alt状态
static bool maybeCtrlJ = false; //为了区分按下的ctrl+J和enter而准备的变量
static ValueNotifier termFontScale = ValueNotifier(1); //终端字体大小,存储为G.prefs的termFontScale
- static bool isStreamServerStarted = false;
static bool isStreaming = false;
- //static int? streamingPid;
- static String streamingOutput = "";
- static late Pty streamServerPty;
//static int? virglPid;
static ValueNotifier pageIndex = ValueNotifier(0); //主界面索引
static ValueNotifier terminalPageChange = ValueNotifier(true); //更改值,用于刷新小键盘
@@ -533,7 +528,6 @@ class G {
static bool wasAvncEnabled = false;
static bool wasX11Enabled = false;
-
static late SharedPreferences prefs;
}
@@ -777,6 +771,7 @@ ${G.dataPath}/bin/virgl_test_server ${Util.getGlobal("defaultVirglCommand")}""")
}
extraMount += "--mount=\$DATA_DIR/tiny/font:/usr/share/fonts/tiny ";
extraMount += "--mount=\$DATA_DIR/tiny/extra/cmatrix:/home/tiny/.local/bin/cmatrix ";
+ extraMount += "--mount=\$DATA_DIR/tiny/extra/tiny_virtual_mic:/home/tiny/.local/bin/tiny_virtual_mic ";
Util.termWrite(
"""
export DATA_DIR=${G.dataPath}