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}