From 3e45fc50d50190bd6b03507b4665c6fe72920b0b Mon Sep 17 00:00:00 2001 From: kevz Date: Mon, 16 Mar 2026 18:31:23 +0800 Subject: [PATCH] Add background audio methods and types --- src/index.ts | 2 + src/plugins/backgroundAudio.ts | 102 +++++++++++++++++++++++++++++++++ src/plugins/index.ts | 1 + src/types/backgroundAudio.ts | 52 +++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/plugins/backgroundAudio.ts create mode 100644 src/types/backgroundAudio.ts diff --git a/src/index.ts b/src/index.ts index 45fa4da..ec2a832 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ namespace Median { export const auth = plugins.auth; export const auth0 = plugins.auth0; export const autorefresh = plugins.autorefresh; + export const backgroundAudio = plugins.backgroundAudio; export const backgroundLocation = plugins.backgroundLocation; export const backgroundMedia = plugins.backgroundMedia; export const barcode = plugins.barcode; @@ -175,5 +176,6 @@ export default Median; // Types // /////////////////////////////// export { AppsFlyer } from './types/appsflyer.js'; +export { BackgroundAudio } from './types/backgroundAudio.js'; export { HealthBridge } from './types/healthBridge.js'; export { MasterLock } from './types/masterlock.js'; diff --git a/src/plugins/backgroundAudio.ts b/src/plugins/backgroundAudio.ts new file mode 100644 index 0000000..add55bb --- /dev/null +++ b/src/plugins/backgroundAudio.ts @@ -0,0 +1,102 @@ +import { BackgroundAudio } from '../types/backgroundAudio.js'; +import { addCallbackFunction, addCommand, addCommandCallback } from '../utils/index.js'; + +type RecordingStopInternalResponse = { + success: boolean; + base64?: string; + mimeType?: string; + state?: BackgroundAudio.RecordingState; + durationSeconds?: number; + transcript?: string; +}; + +type ConvertResultParams = RecordingStopInternalResponse & { + base64?: string; + mimeType?: string; +}; + +const backgroundAudio = { + checkPermission: function () { + return addCommandCallback('median://backgroundAudio/checkPermission'); + }, + requestPermission: function () { + return addCommandCallback('median://backgroundAudio/requestPermission'); + }, + startRecording: function (params: BackgroundAudio.RecordingStartParams) { + let onRecordingStop: string | undefined; + + if (params?.onRecordingStop) { + const onRecordingStopCallback = params.onRecordingStop; + + const callback = function (result: RecordingStopInternalResponse) { + try { + const data = median_background_audio_convert_result(result); + onRecordingStopCallback(data); + } catch (error) { + onRecordingStopCallback({ success: false, error }); + } + }; + + onRecordingStop = addCallbackFunction(callback); + } + + return addCommandCallback('median://backgroundAudio/startRecording', { + ...params, + onRecordingStop, + }); + }, + stopRecording: async function () { + const result = await addCommandCallback('median://backgroundAudio/stopRecording'); + return median_background_audio_convert_result(result); + }, + pause: function () { + return addCommandCallback('median://backgroundAudio/pause'); + }, + resume: function () { + return addCommandCallback('median://backgroundAudio/resume'); + }, + getStatus: function () { + return addCommandCallback('median://backgroundAudio/getStatus'); + }, + addListener: async function (callback: (data: BackgroundAudio.RecordingEvent) => void) { + const listenerIdCallback = addCallbackFunction(callback, true); + const result = await addCommandCallback('median://backgroundAudio/addListener', { listenerIdCallback }); + if (!result?.listenerId) { + throw 'INVALID_LISTENER_ID'; + } else { + return result.listenerId; + } + }, + removeListener: function (listenerId: string) { + addCommand('median://backgroundAudio/removeListener', { listenerId }); + }, +}; + +function median_background_audio_convert_result(result: ConvertResultParams): BackgroundAudio.RecordingStopResponse { + if (result?.success && result?.base64 && result?.mimeType) { + const fileUri = median_background_audio_base64_to_url(result.base64, result.mimeType); + + return { + success: result.success, + fileUri, + state: result.state, + durationSeconds: result.durationSeconds, + transcript: result.transcript, + }; + } + + return result; +} + +function median_background_audio_base64_to_url(base64: string, mimeType: string) { + const byteChars = atob(base64); + const byteNumbers = new Array(byteChars.length); + for (let i = 0; i < byteChars.length; i++) { + byteNumbers[i] = byteChars.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: mimeType }); + return URL.createObjectURL(blob); +} + +export default backgroundAudio; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index a89434c..df7ad62 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -6,6 +6,7 @@ export { default as appsflyer } from './appsflyer.js'; export { default as auth } from './auth.js'; export { default as auth0 } from './auth0.js'; export { default as autorefresh } from './autorefresh.js'; +export { default as backgroundAudio } from './backgroundAudio.js'; export { default as backgroundLocation } from './backgroundLocation.js'; export { default as backgroundMedia } from './backgroundMedia.js'; export { default as barcode } from './barcode.js'; diff --git a/src/types/backgroundAudio.ts b/src/types/backgroundAudio.ts new file mode 100644 index 0000000..30188fb --- /dev/null +++ b/src/types/backgroundAudio.ts @@ -0,0 +1,52 @@ +export namespace BackgroundAudio { + export type PermissionResponse = { + granted: boolean; + status: 'granted' | 'denied' | 'not-determined' | 'restricted'; + }; + + export type RecordingState = 'idle' | 'recording' | 'paused' | 'stopped' | 'error'; + + export type RecordingStartParams = { + /** Audio format for the recorded file. Default: 'm4a'. */ + format?: 'm4a' | 'wav'; + /** Maximum recording duration in seconds. Default: 3600 (60 min). */ + maxDuration?: number; + /** Enable on-device speech-to-text transcription. Default: false. */ + enableTranscription?: boolean; + /** Locale/language code for on-device STT (e.g. 'en-US'). Default: device locale. */ + sttLanguage?: string; + /** Callback when max duration is hit. */ + onRecordingStop?: (data: RecordingStopResponse) => void; + }; + + export type RecordingStartResponse = { + success: boolean; + state: RecordingState; + }; + + export type RecordingStopResponse = { + success: boolean; + error?: string | unknown; + state?: RecordingState; + /** Local file URI of the recorded audio. */ + fileUri?: string; + /** Duration of the recording in seconds. */ + durationSeconds?: number; + /** Transcript text (populated only when enableTranscription is true). */ + transcript?: string; + }; + + export type RecordingStatusResponse = { + state: RecordingState; + elapsedSeconds?: number; + }; + + export type AddListenerResponse = { + listenerId: string; + }; + + export type RecordingEvent = { + state: RecordingState; + durationSeconds?: number; + }; +}