diff --git a/README.md b/README.md index 7ce0036..164195b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ -# react-native-live-audio-stream -[![npm](https://img.shields.io/npm/v/react-native-live-audio-stream)](https://www.npmjs.com/package/react-native-live-audio-stream) - -Get live audio stream data for React Native. Ideal for live voice recognition (transcribing). +# theInfiTualEr/react-native-live-audio-stream -This module is modified from [react-native-audio-record](https://github.com/goodatlas/react-native-audio-record). Instead of saving to an audio file, it only emit events with live data. By doing this, it can reduce memory usage and eliminate file operation overheads in the case that an audio file is not necessary (e.g. live transcribing). +This package is a modified version of [react-native-live-audio-stream](https://github.com/xiqi/react-native-live-audio-stream) which that itself is a modification of [react-native-audio-record](https://github.com/goodatlas/react-native-audio-record) package. This package adds **play**, **unload** and **generating audio header** functionality to the `react-native-live-audio-stream` package, but **ONLY FOR ANDROID.** -Most of the code was written by the respective original authors. +[![npm](https://img.shields.io/npm/v/react-native-live-audio-stream)](https://www.npmjs.com/package/react-native-live-audio-stream) ## Install ``` -yarn add react-native-live-audio-stream +npm install theInfiTualEr/react-native-live-audio-stream cd ios pod install ``` @@ -33,6 +30,8 @@ Add the following line to ```android/app/src/main/AndroidManifest.xml``` ## Usage ```javascript import LiveAudioStream from 'react-native-live-audio-stream'; +import { PermissionsAndroid } from "react-native"; + const options = { sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition @@ -40,16 +39,35 @@ const options = { bitsPerSample: 16, // 8 or 16, default 16 audioSource: 6, // android only (see below) bufferSize: 4096 // default is 2048 + hasAudioHeader: true // default is false, but you probably need it }; +await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO +); + LiveAudioStream.init(options); LiveAudioStream.on('data', data => { // base64-encoded audio data chunks + + // below line plays the received audio data + // NOTE: this DOES NOT WORK on iOS + LiveAudioStream.addPlay(data); }); ... +// NOTE: `loadPlayer` is not necessary on iOS +LiveAudioStream.loadPlayer(); +// NOTE: `startPlay` is not necessary on iOS +LiveAudioStream.startPlay(); +LiveAudioStream.loadRecorder(); LiveAudioStream.start(); ... LiveAudioStream.stop(); +LiveAudioStream.unloadRecorder(); +// NOTE: `stopPlay` is not necessary on iOS +LiveAudioStream.stopPlay(); +// NOTE: `unloadPlayer` is not necessary on iOS +LiveAudioStream.unloadPlayer(); ... ``` @@ -66,6 +84,7 @@ LiveAudioStream.on('data', data => { ``` ## Credits/References +- [react-native-live-audio-stream](https://github.com/xiqi/react-native-live-audio-stream) - [react-native-audio-record](https://github.com/goodatlas/react-native-audio-record) - iOS [Audio Queues](https://developer.apple.com/library/content/documentation/MusicAudio/Conceptual/AudioQueueProgrammingGuide) - Android [AudioRecord](https://developer.android.com/reference/android/media/AudioRecord.html) diff --git a/android/src/main/java/com/imxiqi/rnliveaudiostream/RNLiveAudioStreamModule.java b/android/src/main/java/com/imxiqi/rnliveaudiostream/RNLiveAudioStreamModule.java index 20f454f..420902f 100644 --- a/android/src/main/java/com/imxiqi/rnliveaudiostream/RNLiveAudioStreamModule.java +++ b/android/src/main/java/com/imxiqi/rnliveaudiostream/RNLiveAudioStreamModule.java @@ -2,9 +2,10 @@ import android.media.AudioFormat; import android.media.AudioRecord; +import android.media.AudioTrack; +import android.media.AudioManager; import android.media.MediaRecorder.AudioSource; import android.util.Base64; -import android.util.Log; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; @@ -24,11 +25,15 @@ public class RNLiveAudioStreamModule extends ReactContextBaseJavaModule { private int channelConfig; private int audioFormat; private int audioSource; + private boolean hasAudioHeader; + private AudioTrack player; private AudioRecord recorder; private int bufferSize; private boolean isRecording; + private byte[] wavHeaders; + public RNLiveAudioStreamModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; @@ -47,17 +52,13 @@ public void init(ReadableMap options) { } channelConfig = AudioFormat.CHANNEL_IN_MONO; - if (options.hasKey("channels")) { - if (options.getInt("channels") == 2) { - channelConfig = AudioFormat.CHANNEL_IN_STEREO; - } + if (options.hasKey("channels") && options.getInt("channels") == 2) { + channelConfig = AudioFormat.CHANNEL_IN_STEREO; } audioFormat = AudioFormat.ENCODING_PCM_16BIT; - if (options.hasKey("bitsPerSample")) { - if (options.getInt("bitsPerSample") == 8) { - audioFormat = AudioFormat.ENCODING_PCM_8BIT; - } + if (options.hasKey("bitsPerSample") && options.getInt("bitsPerSample") == 8) { + audioFormat = AudioFormat.ENCODING_PCM_8BIT; } audioSource = AudioSource.VOICE_RECOGNITION; @@ -65,17 +66,24 @@ public void init(ReadableMap options) { audioSource = options.getInt("audioSource"); } + hasAudioHeader = false; + if (options.hasKey("hasAudioHeader")) { + hasAudioHeader = options.getBoolean("hasAudioHeader"); + } + isRecording = false; eventEmitter = reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class); bufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat); - if (options.hasKey("bufferSize")) { bufferSize = Math.max(bufferSize, options.getInt("bufferSize")); } - int recordingBufferSize = bufferSize * 3; - recorder = new AudioRecord(audioSource, sampleRateInHz, channelConfig, audioFormat, recordingBufferSize); + // TODO recordingBufferSize or bufferSize ? + // int recordingBufferSize = bufferSize * 3; + long totalAudioLen = bufferSize; + long totalDataLen = totalAudioLen + 36; + wavHeaders = genWavHeader(totalAudioLen, totalDataLen); } @ReactMethod @@ -96,7 +104,18 @@ public void run() { // skip first 2 buffers to eliminate "click sound" if (bytesRead > 0 && ++count > 2) { - base64Data = Base64.encodeToString(buffer, Base64.NO_WRAP); + if (!hasAudioHeader) { + base64Data = Base64.encodeToString(buffer, Base64.NO_WRAP); + eventEmitter.emit("data", base64Data); + continue; + } + + byte[] combined = new byte[wavHeaders.length + buffer.length]; + System.arraycopy(wavHeaders, 0, combined, 0, wavHeaders.length); + System.arraycopy(buffer, 0, combined, wavHeaders.length, buffer.length); + + base64Data = Base64.encodeToString(combined, Base64.NO_WRAP); + eventEmitter.emit("data", base64Data); } } @@ -114,4 +133,116 @@ public void run() { public void stop(Promise promise) { isRecording = false; } + + @ReactMethod + public void startPlay() { + player.play(); + } + + @ReactMethod + public void addPlay(String audioBufferBase64) { + // player.flush(); + byte[] audioBuffer = Base64.decode(audioBufferBase64, Base64.NO_WRAP); + player.write(audioBuffer, 0, audioBuffer.length); + } + + @ReactMethod + public void stopPlay() { + player.stop(); + } + + @ReactMethod + public void loadPlayer() { + // more info: + // https://stackoverflow.com/questions/9413998/live-audio-recording-and-playing-in-android-and-thread-callback-handling + // TODO recordingBufferSize or bufferSize ? + // int recordingBufferSize = bufferSize * 3; + player = new AudioTrack(AudioManager.STREAM_MUSIC, + sampleRateInHz, channelConfig, audioFormat, + bufferSize, AudioTrack.MODE_STREAM); + player.setPlaybackRate(sampleRateInHz); + } + + @ReactMethod + public void unloadPlayer() { + player.release(); + } + + @ReactMethod + public void loadRecorder() { + // int recordingBufferSize = bufferSize * 3; + recorder = new AudioRecord(audioSource, sampleRateInHz, channelConfig, audioFormat, bufferSize); + } + + @ReactMethod + public void unloadRecorder() { + recorder.release(); + } + + @ReactMethod + public void addListener(String eventName) { + // Keep: Required for RN built in Event Emitter Calls. + } + + @ReactMethod + public void removeListeners(Integer count) { + // Keep: Required for RN built in Event Emitter Calls. + } + + private byte[] genWavHeader(long totalAudioLen, long totalDataLen) { + long sampleRate = sampleRateInHz; + int channels = channelConfig == AudioFormat.CHANNEL_IN_MONO ? 1 : 2; + int bitsPerSample = audioFormat == AudioFormat.ENCODING_PCM_8BIT ? 8 : 16; + long byteRate = sampleRate * channels * bitsPerSample / 8; + int blockAlign = channels * bitsPerSample / 8; + + byte[] header = new byte[44]; + + header[0] = 'R'; // RIFF chunk + header[1] = 'I'; + header[2] = 'F'; + header[3] = 'F'; + header[4] = (byte) (totalDataLen & 0xff); // how big is the rest of this file + header[5] = (byte) ((totalDataLen >> 8) & 0xff); + header[6] = (byte) ((totalDataLen >> 16) & 0xff); + header[7] = (byte) ((totalDataLen >> 24) & 0xff); + header[8] = 'W'; // WAVE chunk + header[9] = 'A'; + header[10] = 'V'; + header[11] = 'E'; + header[12] = 'f'; // 'fmt ' chunk + header[13] = 'm'; + header[14] = 't'; + header[15] = ' '; + header[16] = 16; // 4 bytes: size of 'fmt ' chunk + header[17] = 0; + header[18] = 0; + header[19] = 0; + header[20] = 1; // format = 1 for PCM + header[21] = 0; + header[22] = (byte) channels; // mono or stereo + header[23] = 0; + header[24] = (byte) (sampleRate & 0xff); // samples per second + header[25] = (byte) ((sampleRate >> 8) & 0xff); + header[26] = (byte) ((sampleRate >> 16) & 0xff); + header[27] = (byte) ((sampleRate >> 24) & 0xff); + header[28] = (byte) (byteRate & 0xff); // bytes per second + header[29] = (byte) ((byteRate >> 8) & 0xff); + header[30] = (byte) ((byteRate >> 16) & 0xff); + header[31] = (byte) ((byteRate >> 24) & 0xff); + header[32] = (byte) blockAlign; // bytes in one sample, for all channels + header[33] = 0; + header[34] = (byte) bitsPerSample; // bits in a sample + header[35] = 0; + header[36] = 'd'; // beginning of the data chunk + header[37] = 'a'; + header[38] = 't'; + header[39] = 'a'; + header[40] = (byte) (totalAudioLen & 0xff); // how big is this data chunk + header[41] = (byte) ((totalAudioLen >> 8) & 0xff); + header[42] = (byte) ((totalAudioLen >> 16) & 0xff); + header[43] = (byte) ((totalAudioLen >> 24) & 0xff); + + return header; + } } diff --git a/index.d.ts b/index.d.ts index b846438..ad56fe8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,54 @@ declare module "react-native-live-audio-stream" { export interface IAudioRecord { init: (options: Options) => void + /** + * make sure to call `init` before this, + * starts recording. + */ start: () => void - stop: () => Promise + /** + * stops recording. + */ + stop: () => void + /** + * has to be called before playing audio. + * NOTE: this DOES NOT WORK on iOS + */ + loadPlayer: () => void + /** + * unloads the player resources, can be called on unmount. + * NOTE: this DOES NOT WORK on iOS + */ + unloadPlayer: () => void + /** + * has to be called before playing recorder. + * NOTE: this DOES NOT WORK on iOS + */ + loadRecorder: () => void + /** + * unloads the recorder resources, can be called on unmount. + * NOTE: this DOES NOT WORK on iOS + */ + unloadRecorder: () => void + /** + * make sure to call `init` before this + * NOTE: this DOES NOT WORK on iOS + */ + startPlay: () => void; + /** + * NOTE: this DOES NOT WORK on iOS + * @param audioBufferBase64 same data that you got on `data` event + */ + addPlay: (audioBufferBase64: string) => void; + /** + * NOTE: this DOES NOT WORK on iOS + */ + stopPlay: () => void; + /** + * + * @param event + * @param callback provides data as base64 header-less wave audio + */ on: (event: "data", callback: (data: string) => void) => void } @@ -20,8 +66,11 @@ declare module "react-native-live-audio-stream" { * - `6` */ audioSource?: number - wavFile: string bufferSize?: number + /** + * you probably want this to be true if you want to play it elsewhere. + */ + hasAudioHeader?: boolean } const AudioRecord: IAudioRecord diff --git a/index.ios.js b/index.ios.js new file mode 100644 index 0000000..ad6640f --- /dev/null +++ b/index.ios.js @@ -0,0 +1,31 @@ +import { NativeModules, NativeEventEmitter } from 'react-native'; +const { RNLiveAudioStream } = NativeModules; +const EventEmitter = new NativeEventEmitter(RNLiveAudioStream); + +const AudioRecord = {}; + +AudioRecord.init = options => RNLiveAudioStream.init(options); +AudioRecord.loadPlayer = () => {}; +AudioRecord.unloadPlayer = () => {}; +AudioRecord.loadRecorder = () => {}; +AudioRecord.unloadRecorder = () => {}; +AudioRecord.start = () => RNLiveAudioStream.start(); +AudioRecord.stop = () => RNLiveAudioStream.stop(); +AudioRecord.startPlay = () => {}; +AudioRecord.addPlay = () => {}; +AudioRecord.stopPlay = () => {}; + +const eventsMap = { + data: 'data' +}; + +AudioRecord.on = (event, callback) => { + const nativeEvent = eventsMap[event]; + if (!nativeEvent) { + throw new Error('Invalid event'); + } + EventEmitter.removeAllListeners(nativeEvent); + return EventEmitter.addListener(nativeEvent, callback); +}; + +export default AudioRecord; diff --git a/index.js b/index.js index c5968b2..3a29705 100644 --- a/index.js +++ b/index.js @@ -5,8 +5,15 @@ const EventEmitter = new NativeEventEmitter(RNLiveAudioStream); const AudioRecord = {}; AudioRecord.init = options => RNLiveAudioStream.init(options); +AudioRecord.loadPlayer = () => RNLiveAudioStream.loadPlayer(); +AudioRecord.unloadPlayer = () => RNLiveAudioStream.unloadPlayer(); +AudioRecord.loadRecorder = () => RNLiveAudioStream.loadRecorder(); +AudioRecord.unloadRecorder = () => RNLiveAudioStream.unloadRecorder(); AudioRecord.start = () => RNLiveAudioStream.start(); AudioRecord.stop = () => RNLiveAudioStream.stop(); +AudioRecord.startPlay = () => RNLiveAudioStream.startPlay(); +AudioRecord.addPlay = (audioBufferBase64) => RNLiveAudioStream.addPlay(audioBufferBase64); +AudioRecord.stopPlay = () => RNLiveAudioStream.stopPlay(); const eventsMap = { data: 'data'