From 523acd3349f7afb096ef8fc33ce6c40c9d946da1 Mon Sep 17 00:00:00 2001 From: intelligence Date: Sat, 29 Aug 2026 23:11:50 +0200 Subject: [PATCH 1/2] feat:mp3 --- .gitmodules | 3 + AGENTS.md | 3 +- CMakeLists.txt | 22 +++- README.md | 4 +- android/app/build.gradle | 12 ++ android/app/src/main/cpp/CMakeLists.txt | 24 ++++ android/app/src/main/cpp/native_mp3_encoder.c | 33 +++++ .../io/github/transui/android/Mp3Encoder.java | 22 ++++ .../transui/android/TranscriptionClient.java | 18 +-- .../android/TransuiRecognitionService.java | 11 +- .../android/VoiceRecordingController.java | 10 +- .../io/github/transui/android/WavEncoder.java | 44 ------- .../transui/android/WavEncoderTest.java | 35 ----- macos/Sources/Transui/AppModel.swift | 6 +- macos/Sources/Transui/MP3Encoder.swift | 90 +++++++++++++ .../Sources/Transui/TranscriptionClient.swift | 4 +- .../Sources/Transui/Transui-Bridging-Header.h | 1 + .../Tests/TransuiTests/MP3EncoderTests.swift | 45 +++++++ macos/project.yml | 7 + tests/mp3_encoder_test.c | 33 +++++ third_party/shine | 1 + third_party/transui_mp3/transui_mp3.c | 123 ++++++++++++++++++ third_party/transui_mp3/transui_mp3.h | 29 +++++ 23 files changed, 482 insertions(+), 98 deletions(-) create mode 100644 .gitmodules create mode 100644 android/app/src/main/cpp/CMakeLists.txt create mode 100644 android/app/src/main/cpp/native_mp3_encoder.c create mode 100644 android/app/src/main/java/io/github/transui/android/Mp3Encoder.java delete mode 100644 android/app/src/main/java/io/github/transui/android/WavEncoder.java delete mode 100644 android/app/src/test/java/io/github/transui/android/WavEncoderTest.java create mode 100644 macos/Sources/Transui/MP3Encoder.swift create mode 100644 macos/Sources/Transui/Transui-Bridging-Header.h create mode 100644 macos/Tests/TransuiTests/MP3EncoderTests.swift create mode 100644 tests/mp3_encoder_test.c create mode 160000 third_party/shine create mode 100644 third_party/transui_mp3/transui_mp3.c create mode 100644 third_party/transui_mp3/transui_mp3.h diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..edff972 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/shine"] + path = third_party/shine + url = https://github.com/toots/shine.git diff --git a/AGENTS.md b/AGENTS.md index 3b7a8f0..2642a38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ - 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 …` or `language …` prefix. +- All clients upload 16 kHz mono, 32 kbps MP3 to OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …` or `language …` prefix. +- MP3 encoding uses the `third_party/shine` submodule pinned to `ab5e3526b64af1a2eaa43aa6f441a7312e013519` (`3.1.1-59-gab5e352`). - 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 261d273..32d6a6f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(transui VERSION 0.1.1 LANGUAGES CXX) +project(transui VERSION 0.1.1 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -31,6 +31,26 @@ if(BUILD_TESTING) ) target_include_directories(transcript_cleaner_test PRIVATE src) add_test(NAME transcript_cleaner COMMAND transcript_cleaner_test) + + add_executable(mp3_encoder_test + tests/mp3_encoder_test.c + third_party/transui_mp3/transui_mp3.c + third_party/shine/src/lib/bitstream.c + third_party/shine/src/lib/huffman.c + third_party/shine/src/lib/l3bitstream.c + third_party/shine/src/lib/l3loop.c + third_party/shine/src/lib/l3mdct.c + third_party/shine/src/lib/l3subband.c + third_party/shine/src/lib/layer3.c + third_party/shine/src/lib/reservoir.c + third_party/shine/src/lib/tables.c + ) + target_include_directories(mp3_encoder_test PRIVATE + third_party/transui_mp3 + third_party/shine/src/lib + ) + target_link_libraries(mp3_encoder_test PRIVATE m) + add_test(NAME mp3_encoder COMMAND mp3_encoder_test) endif() set(CPACK_GENERATOR "DEB") diff --git a/README.md b/README.md index 8dc3721..b5b2496 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \ On macOS, install Xcode 16 and XcodeGen. +For Android, install Android SDK 35, the Android NDK, and CMake. + ## Build ### Linux @@ -39,7 +41,7 @@ Build the `Transui` scheme in Xcode. ### Android Android release APKs build in Gitea CI. To run local checks, configure the -Android SDK and run: +Android SDK and NDK, then run: ```sh cd android diff --git a/android/app/build.gradle b/android/app/build.gradle index 9ff531c..e07e2a0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,6 +14,12 @@ android { versionName '0.1.1' testInstrumentationRunner 'android.test.InstrumentationTestRunner' + + externalNativeBuild { + cmake { + arguments '-DANDROID_STL=none' + } + } } signingConfigs { @@ -46,6 +52,12 @@ android { checkReleaseBuilds true warningsAsErrors true } + + externalNativeBuild { + cmake { + path file('src/main/cpp/CMakeLists.txt') + } + } } dependencies { diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..c036314 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.22.1) +project(transui_mp3 LANGUAGES C) + +set(SHINE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/shine/src/lib") +set(TRANSUI_MP3_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/transui_mp3") + +add_library(transui_mp3 SHARED + native_mp3_encoder.c + "${TRANSUI_MP3_DIR}/transui_mp3.c" + "${SHINE_DIR}/bitstream.c" + "${SHINE_DIR}/huffman.c" + "${SHINE_DIR}/l3bitstream.c" + "${SHINE_DIR}/l3loop.c" + "${SHINE_DIR}/l3mdct.c" + "${SHINE_DIR}/l3subband.c" + "${SHINE_DIR}/layer3.c" + "${SHINE_DIR}/reservoir.c" + "${SHINE_DIR}/tables.c" +) + +target_compile_features(transui_mp3 PRIVATE c_std_11) +target_compile_options(transui_mp3 PRIVATE -Wall -Wextra) +target_include_directories(transui_mp3 PRIVATE "${TRANSUI_MP3_DIR}" "${SHINE_DIR}") +target_link_libraries(transui_mp3 PRIVATE m) diff --git a/android/app/src/main/cpp/native_mp3_encoder.c b/android/app/src/main/cpp/native_mp3_encoder.c new file mode 100644 index 0000000..68d0436 --- /dev/null +++ b/android/app/src/main/cpp/native_mp3_encoder.c @@ -0,0 +1,33 @@ +#include + +#include "transui_mp3.h" + +JNIEXPORT jbyteArray JNICALL +Java_io_github_transui_android_Mp3Encoder_encodeNative(JNIEnv *env, + jclass type, + jbyteArray pcm) { + (void)type; + if (!pcm) return NULL; + + const jsize pcm_size = (*env)->GetArrayLength(env, pcm); + jbyte *pcm_bytes = (*env)->GetByteArrayElements(env, pcm, NULL); + if (!pcm_bytes) return NULL; + + uint8_t *mp3 = NULL; + size_t mp3_size = 0; + const int result = transui_mp3_encode_mono_16k_32kbps( + (const uint8_t *)pcm_bytes, (size_t)pcm_size, &mp3, &mp3_size); + (*env)->ReleaseByteArrayElements(env, pcm, pcm_bytes, JNI_ABORT); + if (result != TRANSUI_MP3_OK || !mp3 || mp3_size > INT32_MAX) { + transui_mp3_free(mp3); + return NULL; + } + + jbyteArray output = (*env)->NewByteArray(env, (jsize)mp3_size); + if (output) { + (*env)->SetByteArrayRegion(env, output, 0, (jsize)mp3_size, + (const jbyte *)mp3); + } + transui_mp3_free(mp3); + return output; +} diff --git a/android/app/src/main/java/io/github/transui/android/Mp3Encoder.java b/android/app/src/main/java/io/github/transui/android/Mp3Encoder.java new file mode 100644 index 0000000..50471ee --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/Mp3Encoder.java @@ -0,0 +1,22 @@ +package io.github.transui.android; + +final class Mp3Encoder { + static { + System.loadLibrary("transui_mp3"); + } + + private Mp3Encoder() {} + + static byte[] encodePcm16Mono(byte[] pcm) { + if (pcm == null || pcm.length == 0 || pcm.length % 2 != 0) { + throw new IllegalArgumentException("PCM input must contain complete 16-bit samples"); + } + byte[] mp3 = encodeNative(pcm); + if (mp3 == null || mp3.length == 0) { + throw new IllegalStateException("MP3 encoding failed"); + } + return mp3; + } + + private static native byte[] encodeNative(byte[] pcm); +} diff --git a/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java b/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java index 5436d6b..f12cf7c 100644 --- a/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java +++ b/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java @@ -19,11 +19,11 @@ final class TranscriptionClient { private static final int MAX_RESPONSE_BYTES = 1024 * 1024; private volatile HttpURLConnection activeConnection; - Result transcribe(AppConfig config, byte[] wav) { + Result transcribe(AppConfig config, byte[] mp3) { 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); + return request(config.endpoint, config.model, config.apiKey, config.prompt, mp3); } void cancel() { @@ -36,11 +36,11 @@ final class TranscriptionClient { String model, String key, String prompt, - byte[] wav) { + byte[] mp3) { HttpURLConnection connection = null; try { String boundary = "transui-" + UUID.randomUUID(); - byte[] requestBody = multipartBody(boundary, model, prompt, wav); + byte[] requestBody = multipartBody(boundary, model, prompt, mp3); connection = (HttpURLConnection) new URL(endpointUrl(endpoint)).openConnection(); activeConnection = connection; connection.setConnectTimeout(12_000); @@ -86,17 +86,17 @@ final class TranscriptionClient { return result + "/v1/audio/transcriptions"; } - private static byte[] multipartBody(String boundary, String model, String prompt, byte[] wav) + private static byte[] multipartBody(String boundary, String model, String prompt, byte[] mp3) throws IOException { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(wav.length + 1024); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(mp3.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("Content-Disposition: form-data; name=\"file\"; filename=\"recording.mp3\"\r\n"); + output.writeBytes("Content-Type: audio/mpeg\r\n\r\n"); + output.write(mp3); output.writeBytes("\r\n--" + boundary + "--\r\n"); } return bytes.toByteArray(); diff --git a/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java b/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java index 80cafff..db98eed 100644 --- a/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java +++ b/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java @@ -124,8 +124,15 @@ public final class TransuiRecognitionService extends RecognitionService { if (pcm == null || cancelled.get()) return; sendEndOfSpeech(); if (cancelled.get()) return; - TranscriptionClient.Result result = client.transcribe( - config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE)); + byte[] mp3; + try { + mp3 = Mp3Encoder.encodePcm16Mono(pcm); + } catch (IllegalArgumentException | IllegalStateException error) { + Log.e(TAG, "MP3 encoding failed", error); + sendError(callback, SpeechRecognizer.ERROR_AUDIO); + return; + } + TranscriptionClient.Result result = client.transcribe(config, mp3); if (cancelled.get()) return; if (result.text == null) { Log.w(TAG, result.error); diff --git a/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java b/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java index e179349..da8a030 100644 --- a/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java +++ b/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java @@ -67,8 +67,14 @@ final class VoiceRecordingController { byte[] pcm = captureAudio(); if (pcm == null || cancelled.get()) return; listener.onTranscribing(); - TranscriptionClient.Result result = client.transcribe( - config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE)); + byte[] mp3; + try { + mp3 = Mp3Encoder.encodePcm16Mono(pcm); + } catch (IllegalArgumentException | IllegalStateException error) { + listener.onError(SpeechRecognizer.ERROR_AUDIO); + return; + } + TranscriptionClient.Result result = client.transcribe(config, mp3); if (cancelled.get()) return; if (result.text != null) { listener.onResult(result.text); diff --git a/android/app/src/main/java/io/github/transui/android/WavEncoder.java b/android/app/src/main/java/io/github/transui/android/WavEncoder.java deleted file mode 100644 index d19140b..0000000 --- a/android/app/src/main/java/io/github/transui/android/WavEncoder.java +++ /dev/null @@ -1,44 +0,0 @@ -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); - } -} diff --git a/android/app/src/test/java/io/github/transui/android/WavEncoderTest.java b/android/app/src/test/java/io/github/transui/android/WavEncoderTest.java deleted file mode 100644 index c3185b3..0000000 --- a/android/app/src/test/java/io/github/transui/android/WavEncoderTest.java +++ /dev/null @@ -1,35 +0,0 @@ -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); - } -} diff --git a/macos/Sources/Transui/AppModel.swift b/macos/Sources/Transui/AppModel.swift index 62f1911..70486fa 100644 --- a/macos/Sources/Transui/AppModel.swift +++ b/macos/Sources/Transui/AppModel.swift @@ -145,8 +145,12 @@ final class AppModel: ObservableObject { Task { defer { try? FileManager.default.removeItem(at: recording) } do { + let encoded = try await Task.detached(priority: .userInitiated) { + try MP3Encoder.encode(wav: recording) + }.value + defer { try? FileManager.default.removeItem(at: encoded) } let text = try await transcriptionClient.transcribe( - recording: recording, + recording: encoded, plan: transcriptionPlan, prompt: settings.prompt ) diff --git a/macos/Sources/Transui/MP3Encoder.swift b/macos/Sources/Transui/MP3Encoder.swift new file mode 100644 index 0000000..9b87cff --- /dev/null +++ b/macos/Sources/Transui/MP3Encoder.swift @@ -0,0 +1,90 @@ +import Foundation + +enum MP3EncodingError: LocalizedError { + case invalidRecording + case encodingFailed + + var errorDescription: String? { + switch self { + case .invalidRecording: "The recording could not be read." + case .encodingFailed: "The recording could not be compressed." + } + } +} + +enum MP3Encoder { + static func encode(wav url: URL) throws -> URL { + let wav = try Data(contentsOf: url, options: .mappedIfSafe) + let pcm = try pcmData(fromWAV: wav) + var encodedBytes: UnsafeMutablePointer? + var encodedSize = 0 + let result = pcm.withUnsafeBytes { bytes in + transui_mp3_encode_mono_16k_32kbps( + bytes.bindMemory(to: UInt8.self).baseAddress, + bytes.count, + &encodedBytes, + &encodedSize + ) + } + guard result == TRANSUI_MP3_OK, let encodedBytes, encodedSize > 0 else { + transui_mp3_free(encodedBytes) + throw MP3EncodingError.encodingFailed + } + defer { transui_mp3_free(encodedBytes) } + + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("transui-\(UUID().uuidString).mp3") + try Data(bytes: encodedBytes, count: encodedSize).write(to: output, options: .atomic) + return output + } + + static func pcmData(fromWAV wav: Data) throws -> Data { + guard wav.count >= 12, + String(decoding: wav[0..<4], as: UTF8.self) == "RIFF", + String(decoding: wav[8..<12], as: UTF8.self) == "WAVE" + else { throw MP3EncodingError.invalidRecording } + + var formatIsSupported = false + var audio: Data? + var offset = 12 + while offset <= wav.count - 8 { + let chunkName = String(decoding: wav[offset..<(offset + 4)], as: UTF8.self) + let chunkSize = Int(readUInt32(wav, at: offset + 4)) + let contentOffset = offset + 8 + guard chunkSize >= 0, contentOffset <= wav.count, + chunkSize <= wav.count - contentOffset + else { throw MP3EncodingError.invalidRecording } + + if chunkName == "fmt ", chunkSize >= 16 { + formatIsSupported = readUInt16(wav, at: contentOffset) == 1 + && readUInt16(wav, at: contentOffset + 2) == 1 + && readUInt32(wav, at: contentOffset + 4) == 16_000 + && readUInt16(wav, at: contentOffset + 14) == 16 + } else if chunkName == "data" { + audio = wav.subdata(in: contentOffset..<(contentOffset + chunkSize)) + } + + let paddedSize = chunkSize + (chunkSize & 1) + guard paddedSize <= Int.max - contentOffset else { + throw MP3EncodingError.invalidRecording + } + offset = contentOffset + paddedSize + } + + guard formatIsSupported, let audio, !audio.isEmpty, audio.count.isMultiple(of: 2) else { + throw MP3EncodingError.invalidRecording + } + return audio + } + + private static func readUInt16(_ data: Data, at offset: Int) -> UInt16 { + UInt16(data[offset]) | UInt16(data[offset + 1]) << 8 + } + + private static func readUInt32(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | UInt32(data[offset + 1]) << 8 + | UInt32(data[offset + 2]) << 16 + | UInt32(data[offset + 3]) << 24 + } +} diff --git a/macos/Sources/Transui/TranscriptionClient.swift b/macos/Sources/Transui/TranscriptionClient.swift index 7a3e5f3..beaaccc 100644 --- a/macos/Sources/Transui/TranscriptionClient.swift +++ b/macos/Sources/Transui/TranscriptionClient.swift @@ -146,8 +146,8 @@ struct TranscriptionClient { 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("Content-Disposition: form-data; name=\"file\"; filename=\"recording.mp3\"\r\n") + data.append("Content-Type: audio/mpeg\r\n\r\n") data.append(try Data(contentsOf: recording)) data.append("\r\n--\(boundary)--\r\n") return data diff --git a/macos/Sources/Transui/Transui-Bridging-Header.h b/macos/Sources/Transui/Transui-Bridging-Header.h new file mode 100644 index 0000000..a37c86b --- /dev/null +++ b/macos/Sources/Transui/Transui-Bridging-Header.h @@ -0,0 +1 @@ +#include "transui_mp3.h" diff --git a/macos/Tests/TransuiTests/MP3EncoderTests.swift b/macos/Tests/TransuiTests/MP3EncoderTests.swift new file mode 100644 index 0000000..6366d5a --- /dev/null +++ b/macos/Tests/TransuiTests/MP3EncoderTests.swift @@ -0,0 +1,45 @@ +import XCTest + +@testable import Transui + +final class MP3EncoderTests: XCTestCase { + func testExtractsSupportedPCMDataFromWAV() throws { + let samples = Data([0x01, 0x02, 0x03, 0x04]) + let wav = makeWAV(samples: samples) + + XCTAssertEqual(try MP3Encoder.pcmData(fromWAV: wav), samples) + } + + func testRejectsWrongSampleRate() { + XCTAssertThrowsError(try MP3Encoder.pcmData(fromWAV: makeWAV(sampleRate: 44_100))) + } + + private func makeWAV(sampleRate: UInt32 = 16_000, samples: Data = Data([0, 0])) -> Data { + var data = Data("RIFF".utf8) + append(UInt32(36 + samples.count), to: &data) + data.append(Data("WAVEfmt ".utf8)) + append(UInt32(16), to: &data) + append(UInt16(1), to: &data) + append(UInt16(1), to: &data) + append(sampleRate, to: &data) + append(sampleRate * 2, to: &data) + append(UInt16(2), to: &data) + append(UInt16(16), to: &data) + data.append(Data("data".utf8)) + append(UInt32(samples.count), to: &data) + data.append(samples) + return data + } + + private func append(_ value: UInt16, to data: inout Data) { + data.append(UInt8(value & 0xff)) + data.append(UInt8(value >> 8)) + } + + private func append(_ value: UInt32, to data: inout Data) { + data.append(UInt8(value & 0xff)) + data.append(UInt8((value >> 8) & 0xff)) + data.append(UInt8((value >> 16) & 0xff)) + data.append(UInt8((value >> 24) & 0xff)) + } +} diff --git a/macos/project.yml b/macos/project.yml index 8d44782..d878486 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -24,6 +24,8 @@ targets: deploymentTarget: "15.0" sources: - path: Sources/Transui + - path: ../third_party/transui_mp3 + - path: ../third_party/shine/src/lib settings: base: CODE_SIGN_ENTITLEMENTS: Transui.entitlements @@ -32,6 +34,11 @@ targets: INFOPLIST_FILE: Info.plist PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos PRODUCT_NAME: Transui + GCC_C_LANGUAGE_STANDARD: c11 + HEADER_SEARCH_PATHS: + - "$(SRCROOT)/../third_party/transui_mp3" + - "$(SRCROOT)/../third_party/shine/src/lib" + SWIFT_OBJC_BRIDGING_HEADER: Sources/Transui/Transui-Bridging-Header.h TransuiTests: type: bundle.unit-test diff --git a/tests/mp3_encoder_test.c b/tests/mp3_encoder_test.c new file mode 100644 index 0000000..ad7bc94 --- /dev/null +++ b/tests/mp3_encoder_test.c @@ -0,0 +1,33 @@ +#include "transui_mp3.h" + +#include +#include +#include +#include + +int main(int argc, char **argv) { + const size_t pcm_size = 16000 * 2; + uint8_t *pcm = calloc(pcm_size, 1); + assert(pcm != NULL); + + uint8_t *mp3 = NULL; + size_t mp3_size = 0; + assert(transui_mp3_encode_mono_16k_32kbps(pcm, pcm_size, &mp3, + &mp3_size) == TRANSUI_MP3_OK); + assert(mp3 != NULL); + assert(mp3_size > 2); + assert(mp3[0] == 0xff); + assert((mp3[1] & 0xe0) == 0xe0); + if (argc == 2) { + FILE *output = fopen(argv[1], "wb"); + assert(output != NULL); + assert(fwrite(mp3, 1, mp3_size, output) == mp3_size); + assert(fclose(output) == 0); + } + transui_mp3_free(mp3); + free(pcm); + + assert(transui_mp3_encode_mono_16k_32kbps(NULL, 0, &mp3, &mp3_size) == + TRANSUI_MP3_INVALID_INPUT); + return 0; +} diff --git a/third_party/shine b/third_party/shine new file mode 160000 index 0000000..ab5e352 --- /dev/null +++ b/third_party/shine @@ -0,0 +1 @@ +Subproject commit ab5e3526b64af1a2eaa43aa6f441a7312e013519 diff --git a/third_party/transui_mp3/transui_mp3.c b/third_party/transui_mp3/transui_mp3.c new file mode 100644 index 0000000..fb926fd --- /dev/null +++ b/third_party/transui_mp3/transui_mp3.c @@ -0,0 +1,123 @@ +#include "transui_mp3.h" + +#include "layer3.h" + +#include +#include + +static int append_bytes(uint8_t **output, size_t *size, size_t *capacity, + const unsigned char *bytes, size_t byte_count) { + if (byte_count == 0) return TRANSUI_MP3_OK; + if (*size > SIZE_MAX - byte_count) return TRANSUI_MP3_OUT_OF_MEMORY; + const size_t required = *size + byte_count; + if (required > *capacity) { + size_t next_capacity = *capacity; + while (next_capacity < required) { + if (next_capacity > SIZE_MAX / 2) { + next_capacity = required; + break; + } + next_capacity *= 2; + } + uint8_t *resized = realloc(*output, next_capacity); + if (!resized) return TRANSUI_MP3_OUT_OF_MEMORY; + *output = resized; + *capacity = next_capacity; + } + memcpy(*output + *size, bytes, byte_count); + *size = required; + return TRANSUI_MP3_OK; +} + +int transui_mp3_encode_mono_16k_32kbps(const uint8_t *pcm_le, + size_t pcm_byte_count, + uint8_t **output, + size_t *output_size) { + if (!output || !output_size) return TRANSUI_MP3_INVALID_INPUT; + *output = NULL; + *output_size = 0; + if (!pcm_le || pcm_byte_count < 2 || pcm_byte_count % 2 != 0) { + return TRANSUI_MP3_INVALID_INPUT; + } + if (pcm_byte_count / 8 > SIZE_MAX - 8192) { + return TRANSUI_MP3_OUT_OF_MEMORY; + } + + shine_config_t config; + config.wave.channels = PCM_MONO; + config.wave.samplerate = 16000; + shine_set_config_mpeg_defaults(&config.mpeg); + config.mpeg.mode = MONO; + config.mpeg.bitr = 32; + + shine_t encoder = shine_initialise(&config); + if (!encoder) return TRANSUI_MP3_ENCODER_ERROR; + + const int samples_per_pass = shine_samples_per_pass(encoder); + if (samples_per_pass <= 0) { + shine_close(encoder); + return TRANSUI_MP3_ENCODER_ERROR; + } + int16_t *frame = calloc((size_t)samples_per_pass, sizeof(int16_t)); + size_t capacity = pcm_byte_count / 8 + 8192; + uint8_t *encoded = malloc(capacity); + if (!frame || !encoded) { + free(frame); + free(encoded); + shine_close(encoder); + return TRANSUI_MP3_OUT_OF_MEMORY; + } + + const size_t sample_count = pcm_byte_count / 2; + size_t sample_offset = 0; + size_t encoded_size = 0; + int result = TRANSUI_MP3_OK; + while (sample_offset < sample_count) { + const size_t remaining = sample_count - sample_offset; + const size_t frame_samples = + remaining < (size_t)samples_per_pass ? remaining + : (size_t)samples_per_pass; + memset(frame, 0, (size_t)samples_per_pass * sizeof(int16_t)); + for (size_t index = 0; index < frame_samples; ++index) { + const size_t byte_offset = (sample_offset + index) * 2; + frame[index] = (int16_t)((uint16_t)pcm_le[byte_offset] | + ((uint16_t)pcm_le[byte_offset + 1] << 8)); + } + + int written = 0; + unsigned char *bytes = + shine_encode_buffer_interleaved(encoder, frame, &written); + if (written < 0 || (!bytes && written > 0)) { + result = TRANSUI_MP3_ENCODER_ERROR; + break; + } + result = append_bytes(&encoded, &encoded_size, &capacity, bytes, + (size_t)written); + if (result != TRANSUI_MP3_OK) break; + sample_offset += frame_samples; + } + + if (result == TRANSUI_MP3_OK) { + int written = 0; + unsigned char *bytes = shine_flush(encoder, &written); + if (written < 0 || (!bytes && written > 0)) { + result = TRANSUI_MP3_ENCODER_ERROR; + } else { + result = append_bytes(&encoded, &encoded_size, &capacity, bytes, + (size_t)written); + } + } + + free(frame); + shine_close(encoder); + if (result != TRANSUI_MP3_OK || encoded_size == 0) { + free(encoded); + return result == TRANSUI_MP3_OK ? TRANSUI_MP3_ENCODER_ERROR : result; + } + + *output = encoded; + *output_size = encoded_size; + return TRANSUI_MP3_OK; +} + +void transui_mp3_free(void *memory) { free(memory); } diff --git a/third_party/transui_mp3/transui_mp3.h b/third_party/transui_mp3/transui_mp3.h new file mode 100644 index 0000000..d67bd6c --- /dev/null +++ b/third_party/transui_mp3/transui_mp3.h @@ -0,0 +1,29 @@ +#ifndef TRANSUI_MP3_H +#define TRANSUI_MP3_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + TRANSUI_MP3_OK = 0, + TRANSUI_MP3_INVALID_INPUT = 1, + TRANSUI_MP3_ENCODER_ERROR = 2, + TRANSUI_MP3_OUT_OF_MEMORY = 3, +}; + +int transui_mp3_encode_mono_16k_32kbps(const uint8_t *pcm_le, + size_t pcm_byte_count, + uint8_t **output, + size_t *output_size); + +void transui_mp3_free(void *memory); + +#ifdef __cplusplus +} +#endif + +#endif -- 2.54.0 From 0352a9db39dce94612f147a95be258d28c860447 Mon Sep 17 00:00:00 2001 From: intelligence Date: Sat, 29 Aug 2026 23:32:42 +0200 Subject: [PATCH 2/2] feat:building-and --- .gitea/workflows/android.yml | 2 ++ .gitea/workflows/build.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 668cb21..add5bb2 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -13,6 +13,8 @@ jobs: releases: write steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-java@v4 with: distribution: temurin diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index c04725d..2e13031 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -15,6 +15,8 @@ jobs: CMAKE_BUILD_PARALLEL_LEVEL: 2 steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install dependencies run: | apt-get update @@ -58,6 +60,8 @@ jobs: CMAKE_BUILD_PARALLEL_LEVEL: 2 steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install dependencies run: | apt-get update -- 2.54.0