@automattic/jetpack-ai-client 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.2] - 2024-03-04
9
+ ### Added
10
+ - AI Client: add audio validation hook. [#36043]
11
+ - Voice to Content: Close audio stream on hook destruction [#36086]
12
+
13
+ ### Changed
14
+ - AI Client: change loading and error state handling on media recording hook. [#36001]
15
+ - AI Client: publish audio information on the validation success callback of the audio validation hook. [#36094]
16
+ - Updated package dependencies. [#36095]
17
+ - Updated package dependencies. [#36143]
18
+
19
+ ### Fixed
20
+ - AI Client: fixed transcription request from P2 editor [#36081]
21
+
22
+ ## [0.8.1] - 2024-02-27
23
+ ### Changed
24
+ - AI Client: support audio transcription and transcription post-processing canceling. [#35923]
25
+
8
26
  ## [0.8.0] - 2024-02-26
9
27
  ### Added
10
28
  - Add upgrade message for free tier [#35794]
@@ -229,6 +247,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
229
247
  - Updated package dependencies. [#31659]
230
248
  - Updated package dependencies. [#31785]
231
249
 
250
+ [0.8.2]: https://github.com/Automattic/jetpack-ai-client/compare/v0.8.1...v0.8.2
251
+ [0.8.1]: https://github.com/Automattic/jetpack-ai-client/compare/v0.8.0...v0.8.1
232
252
  [0.8.0]: https://github.com/Automattic/jetpack-ai-client/compare/v0.7.0...v0.8.0
233
253
  [0.7.0]: https://github.com/Automattic/jetpack-ai-client/compare/v0.6.1...v0.7.0
234
254
  [0.6.1]: https://github.com/Automattic/jetpack-ai-client/compare/v0.6.0...v0.6.1
@@ -1,12 +1,9 @@
1
- /**
2
- * Types
3
- */
4
- import { CancelablePromise } from '../types.js';
5
1
  /**
6
2
  * A function that takes an audio blob and transcribes it.
7
3
  *
8
4
  * @param {Blob} audio - The audio to be transcribed, from a recording or from a file.
9
5
  * @param {string} feature - The feature name that is calling the transcription.
6
+ * @param {AbortSignal} requestAbortSignal - The signal to abort the request.
10
7
  * @returns {Promise<string>} - The promise of a string containing the transcribed audio.
11
8
  */
12
- export default function transcribeAudio(audio: Blob, feature?: string): CancelablePromise<string>;
9
+ export default function transcribeAudio(audio: Blob, feature?: string, requestAbortSignal?: AbortSignal): Promise<string>;
@@ -5,7 +5,6 @@ import debugFactory from 'debug';
5
5
  /**
6
6
  * Internal dependencies
7
7
  */
8
- import apiFetch from '../api-fetch/index.js';
9
8
  import requestJwt from '../jwt/index.js';
10
9
  const debug = debugFactory('jetpack-ai-client:audio-transcription');
11
10
  /**
@@ -13,11 +12,10 @@ const debug = debugFactory('jetpack-ai-client:audio-transcription');
13
12
  *
14
13
  * @param {Blob} audio - The audio to be transcribed, from a recording or from a file.
15
14
  * @param {string} feature - The feature name that is calling the transcription.
15
+ * @param {AbortSignal} requestAbortSignal - The signal to abort the request.
16
16
  * @returns {Promise<string>} - The promise of a string containing the transcribed audio.
17
17
  */
18
- export default async function transcribeAudio(audio, feature
19
- // @ts-expect-error Promises are not cancelable by default
20
- ) {
18
+ export default async function transcribeAudio(audio, feature, requestAbortSignal) {
21
19
  debug('Transcribing audio: %o. Feature: %o', audio, feature);
22
20
  // Get a token to use the transcription service
23
21
  let token = '';
@@ -35,14 +33,19 @@ export default async function transcribeAudio(audio, feature
35
33
  const headers = {
36
34
  Authorization: `Bearer ${token}`,
37
35
  };
38
- const response = await apiFetch({
39
- url: `https://public-api.wordpress.com/wpcom/v2/jetpack-ai-transcription${feature ? `?feature=${feature}` : ''}`,
36
+ const URL = `https://public-api.wordpress.com/wpcom/v2/jetpack-ai-transcription${feature ? `?feature=${feature}` : ''}`;
37
+ return fetch(URL, {
40
38
  method: 'POST',
41
39
  body: formData,
42
40
  headers,
41
+ signal: requestAbortSignal ?? undefined,
42
+ }).then(response => {
43
+ debug('Transcription response: %o', response);
44
+ if (response.ok) {
45
+ return response.json().then(data => data?.text);
46
+ }
47
+ return response.json().then(data => Promise.reject(data));
43
48
  });
44
- debug('Transcription response: %o', response);
45
- return response.text;
46
49
  }
47
50
  catch (error) {
48
51
  debug('Transcription error response: %o', error);
@@ -1,7 +1,3 @@
1
- /**
2
- * Types
3
- */
4
- import type { CancelablePromise } from '../../types.js';
5
1
  /**
6
2
  * The response from the audio transcription hook.
7
3
  */
@@ -9,7 +5,8 @@ export type UseAudioTranscriptionReturn = {
9
5
  transcriptionResult: string;
10
6
  isTranscribingAudio: boolean;
11
7
  transcriptionError: string;
12
- transcribeAudio: (audio: Blob) => CancelablePromise;
8
+ transcribeAudio: (audio: Blob) => void;
9
+ cancelTranscription: () => void;
13
10
  };
14
11
  /**
15
12
  * The props for the audio transcription hook.
@@ -1,13 +1,46 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import { useCallback, useState } from '@wordpress/element';
4
+ import { useCallback, useState, useRef } from '@wordpress/element';
5
+ import { __ } from '@wordpress/i18n';
5
6
  import debugFactory from 'debug';
6
7
  /**
7
8
  * Internal dependencies
8
9
  */
9
10
  import transcribeAudio from '../../audio-transcription/index.js';
10
11
  const debug = debugFactory('jetpack-ai-client:use-audio-transcription');
12
+ /**
13
+ * Map error response to a string.
14
+ * @param {Error | string | AudioTranscriptionErrorResponse} error - The error response from the audio transcription service.
15
+ * @returns {string} the translated error message
16
+ */
17
+ const mapErrorResponse = (error) => {
18
+ if (typeof error === 'string') {
19
+ return error;
20
+ }
21
+ if ('code' in error) {
22
+ switch (error.code) {
23
+ case 'error_quota_exceeded':
24
+ return __('You exceeded your current quota, please check your plan details.', 'jetpack-ai-client');
25
+ case 'jetpack_ai_missing_audio_param':
26
+ return __('The audio_file is required to perform a transcription.', 'jetpack-ai-client');
27
+ case 'jetpack_ai_service_unavailable':
28
+ return __('The Jetpack AI service is temporarily unavailable.', 'jetpack-ai-client');
29
+ case 'file_size_not_supported':
30
+ return __('The provided audio file is too big.', 'jetpack-ai-client');
31
+ case 'file_type_not_supported':
32
+ return __('The provided audio file type is not supported.', 'jetpack-ai-client');
33
+ case 'jetpack_ai_error':
34
+ return __('There was an error processing the transcription request.', 'jetpack-ai-client');
35
+ default:
36
+ return error.message;
37
+ }
38
+ }
39
+ if ('message' in error) {
40
+ return error.message;
41
+ }
42
+ return __('There was an error processing the transcription request.', 'jetpack-ai-client');
43
+ };
11
44
  /**
12
45
  * A hook to handle audio transcription.
13
46
  *
@@ -18,6 +51,7 @@ export default function useAudioTranscription({ feature, onReady, onError, }) {
18
51
  const [transcriptionResult, setTranscriptionResult] = useState('');
19
52
  const [transcriptionError, setTranscriptionError] = useState('');
20
53
  const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
54
+ const abortController = useRef(null);
21
55
  const handleAudioTranscription = useCallback((audio) => {
22
56
  debug('Transcribing audio');
23
57
  /**
@@ -26,31 +60,44 @@ export default function useAudioTranscription({ feature, onReady, onError, }) {
26
60
  setTranscriptionResult('');
27
61
  setTranscriptionError('');
28
62
  setIsTranscribingAudio(true);
63
+ /*
64
+ * Create an AbortController to cancel the transcription.
65
+ */
66
+ const controller = new AbortController();
67
+ abortController.current = controller;
29
68
  /**
30
69
  * Call the audio transcription library.
31
70
  */
32
- const promise = transcribeAudio(audio, feature)
71
+ transcribeAudio(audio, feature, controller.signal)
33
72
  .then(transcriptionText => {
34
- if (promise.canceled) {
35
- return;
36
- }
37
73
  setTranscriptionResult(transcriptionText);
38
74
  onReady?.(transcriptionText);
39
75
  })
40
76
  .catch(error => {
41
- if (promise.canceled) {
42
- return;
77
+ if (!controller.signal.aborted) {
78
+ setTranscriptionError(error.message);
79
+ onError?.(mapErrorResponse(error));
43
80
  }
44
- setTranscriptionError(error.message);
45
- onError?.(error.message);
46
81
  })
47
82
  .finally(() => setIsTranscribingAudio(false));
48
- return promise;
49
83
  }, [transcribeAudio, setTranscriptionResult, setTranscriptionError, setIsTranscribingAudio]);
84
+ const handleAudioTranscriptionCancel = useCallback(() => {
85
+ /*
86
+ * Cancel the transcription.
87
+ */
88
+ abortController.current?.abort();
89
+ /*
90
+ * Reset the transcription result and error.
91
+ */
92
+ setTranscriptionResult('');
93
+ setTranscriptionError('');
94
+ setIsTranscribingAudio(false);
95
+ }, [abortController, setTranscriptionResult, setTranscriptionError, setIsTranscribingAudio]);
50
96
  return {
51
97
  transcriptionResult,
52
98
  isTranscribingAudio,
53
99
  transcriptionError,
54
100
  transcribeAudio: handleAudioTranscription,
101
+ cancelTranscription: handleAudioTranscriptionCancel,
55
102
  };
56
103
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The return value for the audio validation hook.
3
+ */
4
+ export type UseAudioValidationReturn = {
5
+ isValidatingAudio: boolean;
6
+ validateAudio: (audio: Blob, successCallback: (info?: ValidatedAudioInformation) => void, errorCallback: (error: string) => void) => void;
7
+ };
8
+ /**
9
+ * The validated audio information.
10
+ */
11
+ export type ValidatedAudioInformation = {
12
+ duration: number;
13
+ isFile: boolean;
14
+ size: number;
15
+ };
16
+ /**
17
+ * Hook to handle the validation of an audio file.
18
+ *
19
+ * @returns {UseAudioValidationReturn} - Object with the audio validation state and the function to validate the audio.
20
+ */
21
+ export default function useAudioValidation(): UseAudioValidationReturn;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { useCallback, useState } from '@wordpress/element';
5
+ import { __ } from '@wordpress/i18n';
6
+ const MAX_AUDIO_SIZE = 25000000; // 25MB
7
+ const MAX_AUDIO_DURATION = 25 * 60; // 25 minutes
8
+ const ALLOWED_MEDIA_TYPES = [
9
+ 'audio/mpeg',
10
+ 'audio/mp3',
11
+ 'audio/ogg',
12
+ 'audio/flac',
13
+ 'audio/x-flac',
14
+ 'audio/m4a',
15
+ 'audio/x-m4a',
16
+ 'audio/mp4',
17
+ 'audio/wav',
18
+ 'audio/wave',
19
+ 'audio/x-wav',
20
+ 'audio/webm',
21
+ ];
22
+ /**
23
+ * Hook to handle the validation of an audio file.
24
+ *
25
+ * @returns {UseAudioValidationReturn} - Object with the audio validation state and the function to validate the audio.
26
+ */
27
+ export default function useAudioValidation() {
28
+ const [isValidatingAudio, setIsValidatingAudio] = useState(false);
29
+ const validateAudio = useCallback((audio, successCallback, errorCallback) => {
30
+ setIsValidatingAudio(true);
31
+ // Check if the audio file is too large
32
+ if (audio?.size > MAX_AUDIO_SIZE) {
33
+ setIsValidatingAudio(false);
34
+ return errorCallback(__('The audio file is too large. The maximum file size is 25MB.', 'jetpack-ai-client'));
35
+ }
36
+ // When it's a file, check the media type
37
+ const isFile = audio instanceof File;
38
+ if (isFile) {
39
+ if (!ALLOWED_MEDIA_TYPES.includes(audio.type)) {
40
+ setIsValidatingAudio(false);
41
+ return errorCallback(__('The audio file type is not supported. Please use a supported audio file type.', 'jetpack-ai-client'));
42
+ }
43
+ }
44
+ // Check the duration of the audio
45
+ const audioContext = new AudioContext();
46
+ // Map blob to an array buffer
47
+ audio.arrayBuffer().then(arrayBuffer => {
48
+ // Decode audio file data contained in an ArrayBuffer
49
+ audioContext.decodeAudioData(arrayBuffer, function (audioBuffer) {
50
+ const duration = Math.ceil(audioBuffer.duration);
51
+ if (duration > MAX_AUDIO_DURATION) {
52
+ setIsValidatingAudio(false);
53
+ return errorCallback(__('The audio file is too long. The maximum recording time is 25 minutes.', 'jetpack-ai-client'));
54
+ }
55
+ setIsValidatingAudio(false);
56
+ return successCallback({ duration, isFile, size: audio?.size });
57
+ });
58
+ });
59
+ }, [setIsValidatingAudio]);
60
+ return { isValidatingAudio, validateAudio };
61
+ }
@@ -1,4 +1,4 @@
1
- export type RecordingState = 'inactive' | 'recording' | 'paused' | 'processing' | 'error';
1
+ export type RecordingState = 'inactive' | 'recording' | 'paused' | 'error';
2
2
  type UseMediaRecordingProps = {
3
3
  onDone?: (blob: Blob) => void;
4
4
  };
@@ -27,10 +27,6 @@ type UseMediaRecordingReturn = {
27
27
  * The error handler
28
28
  */
29
29
  onError: (err: string | Error) => void;
30
- /**
31
- * The processing handler
32
- */
33
- onProcessing: () => void;
34
30
  controls: {
35
31
  /**
36
32
  * `start` recording handler
@@ -11,7 +11,7 @@ import { useRef, useState, useEffect, useCallback } from '@wordpress/element';
11
11
  export default function useMediaRecording({ onDone, } = {}) {
12
12
  // Reference to the media recorder instance
13
13
  const mediaRecordRef = useRef(null);
14
- // Recording state: `inactive`, `recording`, `paused`, `processing`, `error`
14
+ // Recording state: `inactive`, `recording`, `paused`, `error`
15
15
  const [state, setState] = useState('inactive');
16
16
  // reference to the paused state to be used in the `onDataAvailable` event listener,
17
17
  // as the `mediaRecordRef.current.state` is already `inactive` when the recorder is stopped,
@@ -19,6 +19,7 @@ export default function useMediaRecording({ onDone, } = {}) {
19
19
  const isPaused = useRef(false);
20
20
  const recordStartTimestamp = useRef(0);
21
21
  const [duration, setDuration] = useState(0);
22
+ const audioStream = useRef(null);
22
23
  // The recorded blob
23
24
  const [blob, setBlob] = useState(null);
24
25
  // Store the recorded chunks
@@ -111,6 +112,7 @@ export default function useMediaRecording({ onDone, } = {}) {
111
112
  navigator.mediaDevices
112
113
  .getUserMedia(constraints)
113
114
  .then(stream => {
115
+ audioStream.current = stream;
114
116
  const source = audioCtx.createMediaStreamSource(stream);
115
117
  source.connect(analyser.current);
116
118
  mediaRecordRef.current = new MediaRecorder(stream);
@@ -131,10 +133,6 @@ export default function useMediaRecording({ onDone, } = {}) {
131
133
  setError(typeof err === 'string' ? err : err.message);
132
134
  setState('error');
133
135
  }, []);
134
- // manually set the state to `processing` for the file upload case
135
- const onProcessing = useCallback(() => {
136
- setState('processing');
137
- }, []);
138
136
  /**
139
137
  * `start` event listener for the media recorder instance.
140
138
  */
@@ -148,7 +146,6 @@ export default function useMediaRecording({ onDone, } = {}) {
148
146
  * @returns {void}
149
147
  */
150
148
  function onStopListener() {
151
- setState('processing');
152
149
  const lastBlob = getBlob();
153
150
  onDone?.(lastBlob);
154
151
  // Clear the recorded chunks
@@ -192,10 +189,20 @@ export default function useMediaRecording({ onDone, } = {}) {
192
189
  });
193
190
  }
194
191
  }
192
+ /**
193
+ * Close the audio stream
194
+ */
195
+ function closeStream() {
196
+ if (audioStream.current) {
197
+ const tracks = audioStream.current.getTracks();
198
+ tracks.forEach(track => track.stop());
199
+ }
200
+ }
195
201
  // Remove listeners and clear the recorded chunks
196
202
  useEffect(() => {
197
203
  reset();
198
204
  return () => {
205
+ closeStream();
199
206
  clearListeners();
200
207
  };
201
208
  }, []);
@@ -206,7 +213,6 @@ export default function useMediaRecording({ onDone, } = {}) {
206
213
  duration,
207
214
  analyser: analyser.current,
208
215
  onError,
209
- onProcessing,
210
216
  controls: {
211
217
  start,
212
218
  pause,
@@ -11,6 +11,7 @@ export type UseTranscriptionPostProcessingReturn = {
11
11
  isProcessingTranscription: boolean;
12
12
  postProcessingError: string;
13
13
  processTranscription: (action: PostProcessingAction, transcription: string) => void;
14
+ cancelTranscriptionProcessing: () => void;
14
15
  };
15
16
  /**
16
17
  * The props for the transcription post-processing hook.
@@ -38,7 +38,7 @@ export default function useTranscriptionPostProcessing({ feature, onReady, onErr
38
38
  setPostProcessingError(errorData.message);
39
39
  onError?.(errorData.message);
40
40
  }, [setPostProcessingError, onError]);
41
- const { request } = useAiSuggestions({
41
+ const { request, stopSuggestion } = useAiSuggestions({
42
42
  autoRequest: false,
43
43
  onSuggestion: handleOnSuggestion,
44
44
  onDone: handleOnDone,
@@ -75,10 +75,18 @@ export default function useTranscriptionPostProcessing({ feature, onReady, onErr
75
75
  request,
76
76
  feature,
77
77
  ]);
78
+ const handleTranscriptionPostProcessingCancel = useCallback(() => {
79
+ /*
80
+ * Stop the suggestion streaming.
81
+ */
82
+ stopSuggestion();
83
+ setIsProcessingTranscription(false);
84
+ }, [stopSuggestion, setIsProcessingTranscription]);
78
85
  return {
79
86
  postProcessingResult,
80
87
  isProcessingTranscription,
81
88
  postProcessingError,
82
89
  processTranscription: handleTranscriptionPostProcessing,
90
+ cancelTranscriptionProcessing: handleTranscriptionPostProcessingCancel,
83
91
  };
84
92
  }
package/build/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { default as useAiSuggestions } from './hooks/use-ai-suggestions/index.js
6
6
  export { default as useMediaRecording } from './hooks/use-media-recording/index.js';
7
7
  export { default as useAudioTranscription } from './hooks/use-audio-transcription/index.js';
8
8
  export { default as useTranscriptionPostProcessing } from './hooks/use-transcription-post-processing/index.js';
9
+ export { default as useAudioValidation } from './hooks/use-audio-validation/index.js';
9
10
  export * from './icons/index.js';
10
11
  export * from './components/index.js';
11
12
  export * from './data-flow/index.js';
package/build/index.js CHANGED
@@ -12,6 +12,7 @@ export { default as useAiSuggestions } from './hooks/use-ai-suggestions/index.js
12
12
  export { default as useMediaRecording } from './hooks/use-media-recording/index.js';
13
13
  export { default as useAudioTranscription } from './hooks/use-audio-transcription/index.js';
14
14
  export { default as useTranscriptionPostProcessing } from './hooks/use-transcription-post-processing/index.js';
15
+ export { default as useAudioValidation } from './hooks/use-audio-validation/index.js';
15
16
  /*
16
17
  * Components: Icons
17
18
  */
package/build/types.d.ts CHANGED
@@ -17,6 +17,7 @@ export type { UseAiContextOptions } from './data-flow/use-ai-context.js';
17
17
  export type { RequestingErrorProps } from './hooks/use-ai-suggestions/index.js';
18
18
  export type { UseAudioTranscriptionProps, UseAudioTranscriptionReturn, } from './hooks/use-audio-transcription/index.js';
19
19
  export type { UseTranscriptionPostProcessingProps, UseTranscriptionPostProcessingReturn, PostProcessingAction, } from './hooks/use-transcription-post-processing/index.js';
20
+ export type { UseAudioValidationReturn, ValidatedAudioInformation, } from './hooks/use-audio-validation/index.js';
20
21
  export { TRANSCRIPTION_POST_PROCESSING_ACTION_SIMPLE_DRAFT } from './hooks/use-transcription-post-processing/index.js';
21
22
  export declare const REQUESTING_STATES: readonly ["init", "requesting", "suggesting", "done", "error"];
22
23
  export type RequestingStateProp = (typeof REQUESTING_STATES)[number];
@@ -27,6 +28,7 @@ export type { RecordingState } from './hooks/use-media-recording/index.js';
27
28
  export type CancelablePromise<T = void> = Promise<T> & {
28
29
  canceled?: boolean;
29
30
  };
31
+ export type TranscriptionState = RecordingState | 'validating' | 'processing' | 'error';
30
32
  interface JPConnectionInitialState {
31
33
  apiNonce: string;
32
34
  siteSuffix: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "private": false,
3
3
  "name": "@automattic/jetpack-ai-client",
4
- "version": "0.8.0",
4
+ "version": "0.8.2",
5
5
  "description": "A JS client for consuming Jetpack AI services",
6
6
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/ai-client/#readme",
7
7
  "bugs": {
@@ -38,19 +38,19 @@
38
38
  "main": "./build/index.js",
39
39
  "types": "./build/index.d.ts",
40
40
  "dependencies": {
41
- "@automattic/jetpack-base-styles": "^0.6.17",
42
- "@automattic/jetpack-connection": "^0.32.3",
43
- "@automattic/jetpack-shared-extension-utils": "^0.14.2",
41
+ "@automattic/jetpack-base-styles": "^0.6.18",
42
+ "@automattic/jetpack-connection": "^0.33.1",
43
+ "@automattic/jetpack-shared-extension-utils": "^0.14.5",
44
44
  "@microsoft/fetch-event-source": "2.0.1",
45
- "@types/react": "18.2.33",
46
- "@wordpress/api-fetch": "6.48.0",
47
- "@wordpress/block-editor": "12.19.0",
48
- "@wordpress/components": "26.0.0",
49
- "@wordpress/compose": "6.28.0",
50
- "@wordpress/data": "9.21.0",
51
- "@wordpress/element": "5.28.0",
52
- "@wordpress/i18n": "4.51.0",
53
- "@wordpress/icons": "9.42.0",
45
+ "@types/react": "18.2.61",
46
+ "@wordpress/api-fetch": "6.49.0",
47
+ "@wordpress/block-editor": "12.20.0",
48
+ "@wordpress/components": "27.0.0",
49
+ "@wordpress/compose": "6.29.0",
50
+ "@wordpress/data": "9.22.0",
51
+ "@wordpress/element": "5.29.0",
52
+ "@wordpress/i18n": "4.52.0",
53
+ "@wordpress/icons": "9.43.0",
54
54
  "classnames": "2.3.2",
55
55
  "debug": "4.3.4",
56
56
  "react": "18.2.0",
@@ -5,37 +5,23 @@ import debugFactory from 'debug';
5
5
  /**
6
6
  * Internal dependencies
7
7
  */
8
- import apiFetch from '../api-fetch/index.js';
9
8
  import requestJwt from '../jwt/index.js';
10
- /**
11
- * Types
12
- */
13
- import { CancelablePromise } from '../types.js';
14
9
 
15
10
  const debug = debugFactory( 'jetpack-ai-client:audio-transcription' );
16
11
 
17
- /**
18
- * The response from the audio transcription service.
19
- */
20
- type AudioTranscriptionResponse = {
21
- /**
22
- * The transcribed text.
23
- */
24
- text: string;
25
- };
26
-
27
12
  /**
28
13
  * A function that takes an audio blob and transcribes it.
29
14
  *
30
15
  * @param {Blob} audio - The audio to be transcribed, from a recording or from a file.
31
16
  * @param {string} feature - The feature name that is calling the transcription.
17
+ * @param {AbortSignal} requestAbortSignal - The signal to abort the request.
32
18
  * @returns {Promise<string>} - The promise of a string containing the transcribed audio.
33
19
  */
34
20
  export default async function transcribeAudio(
35
21
  audio: Blob,
36
- feature?: string
37
- // @ts-expect-error Promises are not cancelable by default
38
- ): CancelablePromise< string > {
22
+ feature?: string,
23
+ requestAbortSignal?: AbortSignal
24
+ ): Promise< string > {
39
25
  debug( 'Transcribing audio: %o. Feature: %o', audio, feature );
40
26
 
41
27
  // Get a token to use the transcription service
@@ -56,18 +42,22 @@ export default async function transcribeAudio(
56
42
  Authorization: `Bearer ${ token }`,
57
43
  };
58
44
 
59
- const response: AudioTranscriptionResponse = await apiFetch( {
60
- url: `https://public-api.wordpress.com/wpcom/v2/jetpack-ai-transcription${
61
- feature ? `?feature=${ feature }` : ''
62
- }`,
45
+ const URL = `https://public-api.wordpress.com/wpcom/v2/jetpack-ai-transcription${
46
+ feature ? `?feature=${ feature }` : ''
47
+ }`;
48
+
49
+ return fetch( URL, {
63
50
  method: 'POST',
64
51
  body: formData,
65
52
  headers,
53
+ signal: requestAbortSignal ?? undefined,
54
+ } ).then( response => {
55
+ debug( 'Transcription response: %o', response );
56
+ if ( response.ok ) {
57
+ return response.json().then( data => data?.text );
58
+ }
59
+ return response.json().then( data => Promise.reject( data ) );
66
60
  } );
67
-
68
- debug( 'Transcription response: %o', response );
69
-
70
- return response.text;
71
61
  } catch ( error ) {
72
62
  debug( 'Transcription error response: %o', error );
73
63
  return Promise.reject( error );
@@ -1,16 +1,13 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import { useCallback, useState } from '@wordpress/element';
4
+ import { useCallback, useState, useRef } from '@wordpress/element';
5
+ import { __ } from '@wordpress/i18n';
5
6
  import debugFactory from 'debug';
6
7
  /**
7
8
  * Internal dependencies
8
9
  */
9
10
  import transcribeAudio from '../../audio-transcription/index.js';
10
- /**
11
- * Types
12
- */
13
- import type { CancelablePromise } from '../../types.js';
14
11
 
15
12
  const debug = debugFactory( 'jetpack-ai-client:use-audio-transcription' );
16
13
 
@@ -21,7 +18,8 @@ export type UseAudioTranscriptionReturn = {
21
18
  transcriptionResult: string;
22
19
  isTranscribingAudio: boolean;
23
20
  transcriptionError: string;
24
- transcribeAudio: ( audio: Blob ) => CancelablePromise;
21
+ transcribeAudio: ( audio: Blob ) => void;
22
+ cancelTranscription: () => void;
25
23
  };
26
24
 
27
25
  /**
@@ -33,6 +31,63 @@ export type UseAudioTranscriptionProps = {
33
31
  onError?: ( error: string ) => void;
34
32
  };
35
33
 
34
+ /**
35
+ * The error response from the audio transcription service.
36
+ */
37
+ type AudioTranscriptionErrorResponse = {
38
+ /**
39
+ * The error message.
40
+ */
41
+ message: string;
42
+
43
+ /**
44
+ * The error code.
45
+ */
46
+ code: string;
47
+ };
48
+
49
+ /**
50
+ * Map error response to a string.
51
+ * @param {Error | string | AudioTranscriptionErrorResponse} error - The error response from the audio transcription service.
52
+ * @returns {string} the translated error message
53
+ */
54
+ const mapErrorResponse = ( error: Error | string | AudioTranscriptionErrorResponse ): string => {
55
+ if ( typeof error === 'string' ) {
56
+ return error;
57
+ }
58
+
59
+ if ( 'code' in error ) {
60
+ switch ( error.code ) {
61
+ case 'error_quota_exceeded':
62
+ return __(
63
+ 'You exceeded your current quota, please check your plan details.',
64
+ 'jetpack-ai-client'
65
+ );
66
+ case 'jetpack_ai_missing_audio_param':
67
+ return __( 'The audio_file is required to perform a transcription.', 'jetpack-ai-client' );
68
+ case 'jetpack_ai_service_unavailable':
69
+ return __( 'The Jetpack AI service is temporarily unavailable.', 'jetpack-ai-client' );
70
+ case 'file_size_not_supported':
71
+ return __( 'The provided audio file is too big.', 'jetpack-ai-client' );
72
+ case 'file_type_not_supported':
73
+ return __( 'The provided audio file type is not supported.', 'jetpack-ai-client' );
74
+ case 'jetpack_ai_error':
75
+ return __(
76
+ 'There was an error processing the transcription request.',
77
+ 'jetpack-ai-client'
78
+ );
79
+ default:
80
+ return error.message;
81
+ }
82
+ }
83
+
84
+ if ( 'message' in error ) {
85
+ return error.message;
86
+ }
87
+
88
+ return __( 'There was an error processing the transcription request.', 'jetpack-ai-client' );
89
+ };
90
+
36
91
  /**
37
92
  * A hook to handle audio transcription.
38
93
  *
@@ -47,6 +102,7 @@ export default function useAudioTranscription( {
47
102
  const [ transcriptionResult, setTranscriptionResult ] = useState< string >( '' );
48
103
  const [ transcriptionError, setTranscriptionError ] = useState< string >( '' );
49
104
  const [ isTranscribingAudio, setIsTranscribingAudio ] = useState( false );
105
+ const abortController = useRef< AbortController >( null );
50
106
 
51
107
  const handleAudioTranscription = useCallback(
52
108
  ( audio: Blob ) => {
@@ -59,37 +115,49 @@ export default function useAudioTranscription( {
59
115
  setTranscriptionError( '' );
60
116
  setIsTranscribingAudio( true );
61
117
 
118
+ /*
119
+ * Create an AbortController to cancel the transcription.
120
+ */
121
+ const controller = new AbortController();
122
+ abortController.current = controller;
123
+
62
124
  /**
63
125
  * Call the audio transcription library.
64
126
  */
65
- const promise: CancelablePromise = transcribeAudio( audio, feature )
127
+ transcribeAudio( audio, feature, controller.signal )
66
128
  .then( transcriptionText => {
67
- if ( promise.canceled ) {
68
- return;
69
- }
70
-
71
129
  setTranscriptionResult( transcriptionText );
72
130
  onReady?.( transcriptionText );
73
131
  } )
74
132
  .catch( error => {
75
- if ( promise.canceled ) {
76
- return;
133
+ if ( ! controller.signal.aborted ) {
134
+ setTranscriptionError( error.message );
135
+ onError?.( mapErrorResponse( error ) );
77
136
  }
78
-
79
- setTranscriptionError( error.message );
80
- onError?.( error.message );
81
137
  } )
82
138
  .finally( () => setIsTranscribingAudio( false ) );
83
-
84
- return promise;
85
139
  },
86
140
  [ transcribeAudio, setTranscriptionResult, setTranscriptionError, setIsTranscribingAudio ]
87
141
  );
88
142
 
143
+ const handleAudioTranscriptionCancel = useCallback( () => {
144
+ /*
145
+ * Cancel the transcription.
146
+ */
147
+ abortController.current?.abort();
148
+ /*
149
+ * Reset the transcription result and error.
150
+ */
151
+ setTranscriptionResult( '' );
152
+ setTranscriptionError( '' );
153
+ setIsTranscribingAudio( false );
154
+ }, [ abortController, setTranscriptionResult, setTranscriptionError, setIsTranscribingAudio ] );
155
+
89
156
  return {
90
157
  transcriptionResult,
91
158
  isTranscribingAudio,
92
159
  transcriptionError,
93
160
  transcribeAudio: handleAudioTranscription,
161
+ cancelTranscription: handleAudioTranscriptionCancel,
94
162
  };
95
163
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { useCallback, useState } from '@wordpress/element';
5
+ import { __ } from '@wordpress/i18n';
6
+
7
+ const MAX_AUDIO_SIZE = 25000000; // 25MB
8
+ const MAX_AUDIO_DURATION = 25 * 60; // 25 minutes
9
+ const ALLOWED_MEDIA_TYPES = [
10
+ 'audio/mpeg',
11
+ 'audio/mp3',
12
+ 'audio/ogg',
13
+ 'audio/flac',
14
+ 'audio/x-flac',
15
+ 'audio/m4a',
16
+ 'audio/x-m4a',
17
+ 'audio/mp4',
18
+ 'audio/wav',
19
+ 'audio/wave',
20
+ 'audio/x-wav',
21
+ 'audio/webm',
22
+ ];
23
+
24
+ /**
25
+ * The return value for the audio validation hook.
26
+ */
27
+ export type UseAudioValidationReturn = {
28
+ isValidatingAudio: boolean;
29
+ validateAudio: (
30
+ audio: Blob,
31
+ successCallback: ( info?: ValidatedAudioInformation ) => void,
32
+ errorCallback: ( error: string ) => void
33
+ ) => void;
34
+ };
35
+
36
+ /**
37
+ * The validated audio information.
38
+ */
39
+ export type ValidatedAudioInformation = {
40
+ duration: number;
41
+ isFile: boolean;
42
+ size: number;
43
+ };
44
+
45
+ /**
46
+ * Hook to handle the validation of an audio file.
47
+ *
48
+ * @returns {UseAudioValidationReturn} - Object with the audio validation state and the function to validate the audio.
49
+ */
50
+ export default function useAudioValidation(): UseAudioValidationReturn {
51
+ const [ isValidatingAudio, setIsValidatingAudio ] = useState< boolean >( false );
52
+
53
+ const validateAudio = useCallback(
54
+ (
55
+ audio: Blob,
56
+ successCallback: ( info?: ValidatedAudioInformation ) => void,
57
+ errorCallback: ( error: string ) => void
58
+ ) => {
59
+ setIsValidatingAudio( true );
60
+
61
+ // Check if the audio file is too large
62
+ if ( audio?.size > MAX_AUDIO_SIZE ) {
63
+ setIsValidatingAudio( false );
64
+ return errorCallback(
65
+ __( 'The audio file is too large. The maximum file size is 25MB.', 'jetpack-ai-client' )
66
+ );
67
+ }
68
+
69
+ // When it's a file, check the media type
70
+ const isFile = audio instanceof File;
71
+ if ( isFile ) {
72
+ if ( ! ALLOWED_MEDIA_TYPES.includes( audio.type ) ) {
73
+ setIsValidatingAudio( false );
74
+ return errorCallback(
75
+ __(
76
+ 'The audio file type is not supported. Please use a supported audio file type.',
77
+ 'jetpack-ai-client'
78
+ )
79
+ );
80
+ }
81
+ }
82
+
83
+ // Check the duration of the audio
84
+ const audioContext = new AudioContext();
85
+
86
+ // Map blob to an array buffer
87
+ audio.arrayBuffer().then( arrayBuffer => {
88
+ // Decode audio file data contained in an ArrayBuffer
89
+ audioContext.decodeAudioData( arrayBuffer, function ( audioBuffer ) {
90
+ const duration = Math.ceil( audioBuffer.duration );
91
+
92
+ if ( duration > MAX_AUDIO_DURATION ) {
93
+ setIsValidatingAudio( false );
94
+ return errorCallback(
95
+ __(
96
+ 'The audio file is too long. The maximum recording time is 25 minutes.',
97
+ 'jetpack-ai-client'
98
+ )
99
+ );
100
+ }
101
+ setIsValidatingAudio( false );
102
+ return successCallback( { duration, isFile, size: audio?.size } );
103
+ } );
104
+ } );
105
+ },
106
+ [ setIsValidatingAudio ]
107
+ );
108
+
109
+ return { isValidatingAudio, validateAudio };
110
+ }
@@ -5,7 +5,7 @@ import { useRef, useState, useEffect, useCallback } from '@wordpress/element';
5
5
  /*
6
6
  * Types
7
7
  */
8
- export type RecordingState = 'inactive' | 'recording' | 'paused' | 'processing' | 'error';
8
+ export type RecordingState = 'inactive' | 'recording' | 'paused' | 'error';
9
9
  type UseMediaRecordingProps = {
10
10
  onDone?: ( blob: Blob ) => void;
11
11
  };
@@ -41,11 +41,6 @@ type UseMediaRecordingReturn = {
41
41
  */
42
42
  onError: ( err: string | Error ) => void;
43
43
 
44
- /**
45
- * The processing handler
46
- */
47
- onProcessing: () => void;
48
-
49
44
  controls: {
50
45
  /**
51
46
  * `start` recording handler
@@ -90,7 +85,7 @@ export default function useMediaRecording( {
90
85
  // Reference to the media recorder instance
91
86
  const mediaRecordRef = useRef( null );
92
87
 
93
- // Recording state: `inactive`, `recording`, `paused`, `processing`, `error`
88
+ // Recording state: `inactive`, `recording`, `paused`, `error`
94
89
  const [ state, setState ] = useState< RecordingState >( 'inactive' );
95
90
 
96
91
  // reference to the paused state to be used in the `onDataAvailable` event listener,
@@ -101,6 +96,8 @@ export default function useMediaRecording( {
101
96
  const recordStartTimestamp = useRef< number >( 0 );
102
97
  const [ duration, setDuration ] = useState< number >( 0 );
103
98
 
99
+ const audioStream = useRef< MediaStream | null >( null );
100
+
104
101
  // The recorded blob
105
102
  const [ blob, setBlob ] = useState< Blob | null >( null );
106
103
 
@@ -216,6 +213,7 @@ export default function useMediaRecording( {
216
213
  navigator.mediaDevices
217
214
  .getUserMedia( constraints )
218
215
  .then( stream => {
216
+ audioStream.current = stream;
219
217
  const source = audioCtx.createMediaStreamSource( stream );
220
218
  source.connect( analyser.current );
221
219
 
@@ -239,11 +237,6 @@ export default function useMediaRecording( {
239
237
  setState( 'error' );
240
238
  }, [] );
241
239
 
242
- // manually set the state to `processing` for the file upload case
243
- const onProcessing = useCallback( () => {
244
- setState( 'processing' );
245
- }, [] );
246
-
247
240
  /**
248
241
  * `start` event listener for the media recorder instance.
249
242
  */
@@ -258,7 +251,6 @@ export default function useMediaRecording( {
258
251
  * @returns {void}
259
252
  */
260
253
  function onStopListener(): void {
261
- setState( 'processing' );
262
254
  const lastBlob = getBlob();
263
255
  onDone?.( lastBlob );
264
256
 
@@ -310,11 +302,22 @@ export default function useMediaRecording( {
310
302
  }
311
303
  }
312
304
 
305
+ /**
306
+ * Close the audio stream
307
+ */
308
+ function closeStream() {
309
+ if ( audioStream.current ) {
310
+ const tracks = audioStream.current.getTracks();
311
+ tracks.forEach( track => track.stop() );
312
+ }
313
+ }
314
+
313
315
  // Remove listeners and clear the recorded chunks
314
316
  useEffect( () => {
315
317
  reset();
316
318
 
317
319
  return () => {
320
+ closeStream();
318
321
  clearListeners();
319
322
  };
320
323
  }, [] );
@@ -326,7 +329,6 @@ export default function useMediaRecording( {
326
329
  duration,
327
330
  analyser: analyser.current,
328
331
  onError,
329
- onProcessing,
330
332
 
331
333
  controls: {
332
334
  start,
@@ -25,6 +25,7 @@ export type UseTranscriptionPostProcessingReturn = {
25
25
  isProcessingTranscription: boolean;
26
26
  postProcessingError: string;
27
27
  processTranscription: ( action: PostProcessingAction, transcription: string ) => void;
28
+ cancelTranscriptionProcessing: () => void;
28
29
  };
29
30
 
30
31
  /**
@@ -81,7 +82,7 @@ export default function useTranscriptionPostProcessing( {
81
82
  [ setPostProcessingError, onError ]
82
83
  );
83
84
 
84
- const { request } = useAiSuggestions( {
85
+ const { request, stopSuggestion } = useAiSuggestions( {
85
86
  autoRequest: false,
86
87
  onSuggestion: handleOnSuggestion,
87
88
  onDone: handleOnDone,
@@ -126,10 +127,19 @@ export default function useTranscriptionPostProcessing( {
126
127
  ]
127
128
  );
128
129
 
130
+ const handleTranscriptionPostProcessingCancel = useCallback( () => {
131
+ /*
132
+ * Stop the suggestion streaming.
133
+ */
134
+ stopSuggestion();
135
+ setIsProcessingTranscription( false );
136
+ }, [ stopSuggestion, setIsProcessingTranscription ] );
137
+
129
138
  return {
130
139
  postProcessingResult,
131
140
  isProcessingTranscription,
132
141
  postProcessingError,
133
142
  processTranscription: handleTranscriptionPostProcessing,
143
+ cancelTranscriptionProcessing: handleTranscriptionPostProcessingCancel,
134
144
  };
135
145
  }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export { default as useAiSuggestions } from './hooks/use-ai-suggestions/index.js
13
13
  export { default as useMediaRecording } from './hooks/use-media-recording/index.js';
14
14
  export { default as useAudioTranscription } from './hooks/use-audio-transcription/index.js';
15
15
  export { default as useTranscriptionPostProcessing } from './hooks/use-transcription-post-processing/index.js';
16
+ export { default as useAudioValidation } from './hooks/use-audio-validation/index.js';
16
17
 
17
18
  /*
18
19
  * Components: Icons
package/src/types.ts CHANGED
@@ -46,6 +46,11 @@ export type {
46
46
  UseTranscriptionPostProcessingReturn,
47
47
  PostProcessingAction,
48
48
  } from './hooks/use-transcription-post-processing/index.js';
49
+ export type {
50
+ UseAudioValidationReturn,
51
+ ValidatedAudioInformation,
52
+ } from './hooks/use-audio-validation/index.js';
53
+
49
54
  /*
50
55
  * Hook constants
51
56
  */
@@ -88,6 +93,11 @@ export type { RecordingState } from './hooks/use-media-recording/index.js';
88
93
  */
89
94
  export type CancelablePromise< T = void > = Promise< T > & { canceled?: boolean };
90
95
 
96
+ /*
97
+ * Transcription types
98
+ */
99
+ export type TranscriptionState = RecordingState | 'validating' | 'processing' | 'error';
100
+
91
101
  // Connection initial state
92
102
  // @todo: it should be provided by the connection package
93
103
  interface JPConnectionInitialState {