feat:mp3
This commit is contained in:
1 parent
32e7f32e0a
commit
523acd3349
23 files changed
+482
-98
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,33 @@
|
||||
#include <jni.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user