ai 7.0.123 → 7.0.124
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 +14 -0
- package/dist/index.d.ts +191 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +383 -91
- package/dist/index.js.map +1 -1
- package/dist/internal/index.d.ts +199 -8
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +9 -3
- package/dist/internal/index.js.map +1 -1
- package/docs/03-ai-sdk-core/36-transcription.mdx +36 -0
- package/docs/03-ai-sdk-core/37-speech.mdx +21 -0
- package/docs/03-ai-sdk-core/60-telemetry.mdx +49 -1
- package/docs/07-reference/01-ai-sdk-core/11-stream-transcribe.mdx +7 -0
- package/docs/07-reference/01-ai-sdk-core/11-transcribe.mdx +7 -0
- package/docs/07-reference/01-ai-sdk-core/12-generate-speech.mdx +7 -0
- package/package.json +12 -12
- package/src/generate-speech/generate-speech.ts +163 -38
- package/src/generate-speech/index.ts +4 -0
- package/src/generate-speech/speech-events.ts +91 -0
- package/src/telemetry/create-telemetry-dispatcher.ts +18 -5
- package/src/telemetry/telemetry.ts +38 -6
- package/src/telemetry/tracing-channel.ts +3 -0
- package/src/transcribe/index.ts +6 -0
- package/src/transcribe/stream-transcribe.ts +182 -31
- package/src/transcribe/transcribe.ts +186 -33
- package/src/transcribe/transcription-events.ts +111 -0
|
@@ -40,6 +40,16 @@ import type {
|
|
|
40
40
|
RerankingModelCallEndEvent,
|
|
41
41
|
RerankingModelCallStartEvent,
|
|
42
42
|
} from '../rerank/rerank-events';
|
|
43
|
+
import type {
|
|
44
|
+
GenerateSpeechEndEvent,
|
|
45
|
+
GenerateSpeechStartEvent,
|
|
46
|
+
} from '../generate-speech/speech-events';
|
|
47
|
+
import type {
|
|
48
|
+
StreamTranscriptionEndEvent,
|
|
49
|
+
StreamTranscriptionStartEvent,
|
|
50
|
+
TranscriptionEndEvent,
|
|
51
|
+
TranscriptionStartEvent,
|
|
52
|
+
} from '../transcribe/transcription-events';
|
|
43
53
|
import type { Callback } from '../util/callback';
|
|
44
54
|
import type { TelemetryOptions } from '../telemetry/telemetry-options';
|
|
45
55
|
import type { TelemetryTracingEventType } from './tracing-channel';
|
|
@@ -51,17 +61,25 @@ export type InferTelemetryEvent<EVENT> = EVENT &
|
|
|
51
61
|
'integrations' | 'isEnabled' | 'includeRuntimeContext'
|
|
52
62
|
>;
|
|
53
63
|
|
|
64
|
+
type BivariantCallback<EVENT> = {
|
|
65
|
+
bivarianceHack(event: EVENT): PromiseLike<void> | void;
|
|
66
|
+
}['bivarianceHack'];
|
|
67
|
+
|
|
54
68
|
type OperationStartEvent =
|
|
55
69
|
| GenerateTextStartEvent
|
|
56
70
|
| GenerateObjectStartEvent
|
|
57
71
|
| EmbedStartEvent
|
|
58
|
-
| RerankStartEvent
|
|
72
|
+
| RerankStartEvent
|
|
73
|
+
| GenerateSpeechStartEvent
|
|
74
|
+
| TranscriptionStartEvent;
|
|
59
75
|
|
|
60
76
|
type OperationEndEvent =
|
|
61
77
|
| GenerateTextEndEvent<ToolSet>
|
|
62
78
|
| GenerateObjectEndEvent<unknown>
|
|
63
79
|
| EmbedEndEvent
|
|
64
|
-
| RerankEndEvent
|
|
80
|
+
| RerankEndEvent
|
|
81
|
+
| GenerateSpeechEndEvent
|
|
82
|
+
| TranscriptionEndEvent;
|
|
65
83
|
|
|
66
84
|
export interface TelemetryDispatcher {
|
|
67
85
|
/**
|
|
@@ -102,6 +120,8 @@ export interface TelemetryDispatcher {
|
|
|
102
120
|
experimental_onEvaluationModelCallStart?: Callback<EvaluationModelCallStartEvent>;
|
|
103
121
|
experimental_onEvaluationModelCallEnd?: Callback<EvaluationModelCallEndEvent>;
|
|
104
122
|
experimental_onEvaluateEnd?: Callback<EvaluateEndEvent>;
|
|
123
|
+
experimental_onStreamTranscriptionStart?: Callback<StreamTranscriptionStartEvent>;
|
|
124
|
+
experimental_onStreamTranscriptionEnd?: Callback<StreamTranscriptionEndEvent>;
|
|
105
125
|
onEnd?: Callback<OperationEndEvent>;
|
|
106
126
|
onAbort?: Callback<GenerateTextAbortEvent<ToolSet>>;
|
|
107
127
|
onError?: Callback<unknown>;
|
|
@@ -117,11 +137,12 @@ export interface Telemetry {
|
|
|
117
137
|
/**
|
|
118
138
|
* Called when an operation begins. Fired for text generation
|
|
119
139
|
* (generateText/streamText), object generation (generateObject/streamObject),
|
|
120
|
-
* embedding (embed/embedMany),
|
|
140
|
+
* embedding (embed/embedMany), reranking, speech generation, and
|
|
141
|
+
* non-streaming transcription operations.
|
|
121
142
|
*
|
|
122
143
|
* Use the `operationId` field to distinguish between operation types.
|
|
123
144
|
*/
|
|
124
|
-
onStart?:
|
|
145
|
+
onStart?: BivariantCallback<InferTelemetryEvent<OperationStartEvent>>;
|
|
125
146
|
|
|
126
147
|
/**
|
|
127
148
|
* Called when an individual step (single LLM invocation) begins.
|
|
@@ -248,14 +269,25 @@ export interface Telemetry {
|
|
|
248
269
|
/** Called when an experimental evaluation operation completes. */
|
|
249
270
|
experimental_onEvaluateEnd?: Callback<InferTelemetryEvent<EvaluateEndEvent>>;
|
|
250
271
|
|
|
272
|
+
/** Called when an experimental streaming transcription operation begins. */
|
|
273
|
+
experimental_onStreamTranscriptionStart?: Callback<
|
|
274
|
+
InferTelemetryEvent<StreamTranscriptionStartEvent>
|
|
275
|
+
>;
|
|
276
|
+
|
|
277
|
+
/** Called when an experimental streaming transcription operation completes. */
|
|
278
|
+
experimental_onStreamTranscriptionEnd?: Callback<
|
|
279
|
+
InferTelemetryEvent<StreamTranscriptionEndEvent>
|
|
280
|
+
>;
|
|
281
|
+
|
|
251
282
|
/**
|
|
252
283
|
* Called when an operation completes. Fired for text generation
|
|
253
284
|
* (generateText/streamText), object generation (generateObject/streamObject),
|
|
254
|
-
* embedding (embed/embedMany),
|
|
285
|
+
* embedding (embed/embedMany), reranking, speech generation, and
|
|
286
|
+
* non-streaming transcription operations.
|
|
255
287
|
*
|
|
256
288
|
* Use the event shape or `operationId` to distinguish between operation types.
|
|
257
289
|
*/
|
|
258
|
-
onEnd?:
|
|
290
|
+
onEnd?: BivariantCallback<InferTelemetryEvent<OperationEndEvent>>;
|
|
259
291
|
|
|
260
292
|
/**
|
|
261
293
|
* Called when a streaming text generation operation is aborted before it
|
package/src/transcribe/index.ts
CHANGED
|
@@ -8,6 +8,12 @@ export type {
|
|
|
8
8
|
TranscriptionStreamPart,
|
|
9
9
|
} from './stream-transcribe-result';
|
|
10
10
|
export type { TranscriptionResult } from './transcribe-result';
|
|
11
|
+
export type {
|
|
12
|
+
StreamTranscriptionEndEvent as Experimental_StreamTranscriptionEndEvent,
|
|
13
|
+
StreamTranscriptionStartEvent as Experimental_StreamTranscriptionStartEvent,
|
|
14
|
+
TranscriptionEndEvent,
|
|
15
|
+
TranscriptionStartEvent,
|
|
16
|
+
} from './transcription-events';
|
|
11
17
|
|
|
12
18
|
// deprecated exports
|
|
13
19
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type SharedV4AudioFormat,
|
|
6
6
|
} from '@ai-sdk/provider';
|
|
7
7
|
import {
|
|
8
|
+
createIdGenerator,
|
|
8
9
|
DelayedPromise,
|
|
9
10
|
withUserAgentSuffix,
|
|
10
11
|
type ProviderOptions,
|
|
@@ -12,16 +13,23 @@ import {
|
|
|
12
13
|
import { NoTranscriptGeneratedError } from '../error/no-transcript-generated-error';
|
|
13
14
|
import { logWarnings } from '../logger/log-warnings';
|
|
14
15
|
import { resolveTranscriptionModel } from '../model/resolve-model';
|
|
16
|
+
import { createTelemetryDispatcher } from '../telemetry/create-telemetry-dispatcher';
|
|
17
|
+
import type { TelemetryOptions } from '../telemetry/telemetry-options';
|
|
15
18
|
import type { TranscriptionModel } from '../types/transcription-model';
|
|
16
19
|
import type { TranscriptionModelResponseMetadata } from '../types/transcription-model-response-metadata';
|
|
17
20
|
import type { Warning } from '../types/warning';
|
|
18
21
|
import { asAsyncIterableStream } from '../util/async-iterable-stream';
|
|
19
22
|
import { mergeAbortSignals } from '../util/merge-abort-signals';
|
|
23
|
+
import { notify } from '../util/notify';
|
|
20
24
|
import { VERSION } from '../version';
|
|
21
25
|
import type {
|
|
22
26
|
StreamTranscriptionResult,
|
|
23
27
|
TranscriptionStreamPart,
|
|
24
28
|
} from './stream-transcribe-result';
|
|
29
|
+
import type {
|
|
30
|
+
StreamTranscriptionEndEvent,
|
|
31
|
+
StreamTranscriptionStartEvent,
|
|
32
|
+
} from './transcription-events';
|
|
25
33
|
|
|
26
34
|
type TranscriptSegment = {
|
|
27
35
|
text: string;
|
|
@@ -29,6 +37,11 @@ type TranscriptSegment = {
|
|
|
29
37
|
endSecond: number;
|
|
30
38
|
};
|
|
31
39
|
|
|
40
|
+
const originalGenerateCallId = createIdGenerator({
|
|
41
|
+
prefix: 'call',
|
|
42
|
+
size: 24,
|
|
43
|
+
});
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Streams transcripts using a transcription model.
|
|
34
47
|
*
|
|
@@ -38,6 +51,7 @@ type TranscriptSegment = {
|
|
|
38
51
|
* @param providerOptions - Additional provider-specific options.
|
|
39
52
|
* @param abortSignal - An optional abort signal that can be used to cancel the call.
|
|
40
53
|
* @param headers - Additional HTTP/WebSocket headers to send when supported by the provider.
|
|
54
|
+
* @param telemetry - Optional telemetry configuration.
|
|
41
55
|
*
|
|
42
56
|
* @returns A result object that contains the streaming transcript and final transcript metadata.
|
|
43
57
|
*/
|
|
@@ -49,7 +63,11 @@ export function streamTranscribe({
|
|
|
49
63
|
abortSignal,
|
|
50
64
|
headers,
|
|
51
65
|
includeRawChunks,
|
|
52
|
-
|
|
66
|
+
telemetry,
|
|
67
|
+
_internal: {
|
|
68
|
+
currentDate = () => new Date(),
|
|
69
|
+
generateCallId = originalGenerateCallId,
|
|
70
|
+
} = {},
|
|
53
71
|
}: {
|
|
54
72
|
/**
|
|
55
73
|
* The transcription model to use.
|
|
@@ -86,11 +104,17 @@ export function streamTranscribe({
|
|
|
86
104
|
*/
|
|
87
105
|
includeRawChunks?: boolean;
|
|
88
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Optional telemetry configuration.
|
|
109
|
+
*/
|
|
110
|
+
telemetry?: TelemetryOptions;
|
|
111
|
+
|
|
89
112
|
/**
|
|
90
113
|
* Internal test hooks.
|
|
91
114
|
*/
|
|
92
115
|
_internal?: {
|
|
93
116
|
currentDate?: () => Date;
|
|
117
|
+
generateCallId?: () => string;
|
|
94
118
|
};
|
|
95
119
|
}): StreamTranscriptionResult {
|
|
96
120
|
const resolvedModel = resolveTranscriptionModel(model);
|
|
@@ -118,6 +142,34 @@ export function streamTranscribe({
|
|
|
118
142
|
`ai/${VERSION}`,
|
|
119
143
|
);
|
|
120
144
|
|
|
145
|
+
const callId = generateCallId();
|
|
146
|
+
const telemetryDispatcher = createTelemetryDispatcher({ telemetry });
|
|
147
|
+
const runInTracingChannelSpan =
|
|
148
|
+
telemetryDispatcher.runInTracingChannelSpan ??
|
|
149
|
+
(async <T>({ execute }: { execute: () => PromiseLike<T> }) =>
|
|
150
|
+
await execute());
|
|
151
|
+
let audioByteLength = 0;
|
|
152
|
+
const countedAudio =
|
|
153
|
+
telemetryDispatcher.experimental_onStreamTranscriptionEnd != null
|
|
154
|
+
? createByteCountingStream(audio, byteLength => {
|
|
155
|
+
audioByteLength += byteLength;
|
|
156
|
+
})
|
|
157
|
+
: audio;
|
|
158
|
+
const startEvent: StreamTranscriptionStartEvent = {
|
|
159
|
+
callId,
|
|
160
|
+
operationId: 'ai.streamTranscribe',
|
|
161
|
+
provider: resolvedModel.provider,
|
|
162
|
+
modelId: resolvedModel.modelId,
|
|
163
|
+
audio: {
|
|
164
|
+
byteLength: undefined,
|
|
165
|
+
mediaType: inputAudioFormat.type,
|
|
166
|
+
},
|
|
167
|
+
inputAudioFormat,
|
|
168
|
+
maxRetries: undefined,
|
|
169
|
+
headers,
|
|
170
|
+
providerOptions,
|
|
171
|
+
};
|
|
172
|
+
|
|
121
173
|
const textPromise = new DelayedPromise<string>();
|
|
122
174
|
const segmentsPromise = new DelayedPromise<Array<TranscriptSegment>>();
|
|
123
175
|
const languagePromise = new DelayedPromise<string | undefined>();
|
|
@@ -129,6 +181,8 @@ export function streamTranscribe({
|
|
|
129
181
|
const providerMetadataPromise = new DelayedPromise<
|
|
130
182
|
Record<string, JSONObject>
|
|
131
183
|
>();
|
|
184
|
+
let warnings: Array<Warning> = [];
|
|
185
|
+
let telemetrySettled = false;
|
|
132
186
|
|
|
133
187
|
const rejectPendingPromises = (error: unknown) => {
|
|
134
188
|
for (const promise of [
|
|
@@ -151,10 +205,11 @@ export function streamTranscribe({
|
|
|
151
205
|
const currentResponseMetadata = () =>
|
|
152
206
|
response ?? { timestamp: startedAt, modelId: resolvedModel.modelId };
|
|
153
207
|
|
|
154
|
-
const resolveWarnings = (
|
|
155
|
-
|
|
208
|
+
const resolveWarnings = (resolvedWarnings: Array<Warning>) => {
|
|
209
|
+
warnings = resolvedWarnings;
|
|
210
|
+
warningsPromise.resolve(resolvedWarnings);
|
|
156
211
|
logWarnings({
|
|
157
|
-
warnings,
|
|
212
|
+
warnings: resolvedWarnings,
|
|
158
213
|
provider: resolvedModel.provider,
|
|
159
214
|
model: resolvedModel.modelId,
|
|
160
215
|
});
|
|
@@ -172,7 +227,7 @@ export function streamTranscribe({
|
|
|
172
227
|
Experimental_TranscriptionModelV4StreamPart,
|
|
173
228
|
TranscriptionStreamPart
|
|
174
229
|
> & { cancel?: (reason?: unknown) => void } = {
|
|
175
|
-
transform(value, controller) {
|
|
230
|
+
async transform(value, controller) {
|
|
176
231
|
switch (value.type) {
|
|
177
232
|
case 'stream-start': {
|
|
178
233
|
resolveWarnings(value.warnings);
|
|
@@ -214,6 +269,37 @@ export function streamTranscribe({
|
|
|
214
269
|
durationInSecondsPromise.resolve(value.durationInSeconds);
|
|
215
270
|
responsesPromise.resolve([currentResponseMetadata()]);
|
|
216
271
|
providerMetadataPromise.resolve(value.providerMetadata ?? {});
|
|
272
|
+
|
|
273
|
+
telemetrySettled = true;
|
|
274
|
+
if (
|
|
275
|
+
telemetryDispatcher.experimental_onStreamTranscriptionEnd != null
|
|
276
|
+
) {
|
|
277
|
+
const endEvent: StreamTranscriptionEndEvent = {
|
|
278
|
+
callId,
|
|
279
|
+
operationId: 'ai.streamTranscribe',
|
|
280
|
+
provider: resolvedModel.provider,
|
|
281
|
+
modelId: resolvedModel.modelId,
|
|
282
|
+
audio: {
|
|
283
|
+
byteLength: audioByteLength,
|
|
284
|
+
mediaType: inputAudioFormat.type,
|
|
285
|
+
},
|
|
286
|
+
text: value.text,
|
|
287
|
+
segments: value.segments,
|
|
288
|
+
language: value.language,
|
|
289
|
+
durationInSeconds: value.durationInSeconds,
|
|
290
|
+
usage: value.usage,
|
|
291
|
+
warnings,
|
|
292
|
+
providerMetadata: value.providerMetadata,
|
|
293
|
+
response: currentResponseMetadata(),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
await notify({
|
|
297
|
+
event: endEvent,
|
|
298
|
+
callbacks: [
|
|
299
|
+
telemetryDispatcher.experimental_onStreamTranscriptionEnd,
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
217
303
|
break;
|
|
218
304
|
}
|
|
219
305
|
}
|
|
@@ -241,36 +327,50 @@ export function streamTranscribe({
|
|
|
241
327
|
|
|
242
328
|
// Piping (instead of an eager read loop) preserves consumer backpressure
|
|
243
329
|
// and propagates cancellation of `fullStream` to the model stream.
|
|
244
|
-
void (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
330
|
+
void runInTracingChannelSpan({
|
|
331
|
+
type: 'streamTranscribe',
|
|
332
|
+
event: startEvent,
|
|
333
|
+
execute: async () => {
|
|
334
|
+
await notify({
|
|
335
|
+
event: startEvent,
|
|
336
|
+
callbacks: [
|
|
337
|
+
telemetryDispatcher.experimental_onStreamTranscriptionStart,
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const result = await doStream({
|
|
342
|
+
audio: countedAudio,
|
|
343
|
+
inputAudioFormat,
|
|
344
|
+
providerOptions,
|
|
345
|
+
// merged so cancelling fullStream also aborts a still-pending doStream
|
|
346
|
+
abortSignal: mergeAbortSignals(abortSignal, pipeAbortController.signal),
|
|
347
|
+
headers: headersWithUserAgent,
|
|
348
|
+
includeRawChunks,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
response = {
|
|
352
|
+
timestamp: result.response?.timestamp ?? startedAt,
|
|
353
|
+
modelId: result.response?.modelId ?? resolvedModel.modelId,
|
|
354
|
+
headers: result.response?.headers,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await result.stream.pipeTo(transform.writable, {
|
|
358
|
+
signal: pipeAbortController.signal,
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
}).catch(async error => {
|
|
265
362
|
const reason =
|
|
266
363
|
error ?? new Error('Transcription stream was cancelled or errored.');
|
|
267
364
|
rejectPendingPromises(reason);
|
|
365
|
+
if (!telemetrySettled) {
|
|
366
|
+
telemetrySettled = true;
|
|
367
|
+
await telemetryDispatcher.onError?.({ callId, error: reason });
|
|
368
|
+
}
|
|
268
369
|
// When `doStream` rejects before the model stream exists (e.g. auth or
|
|
269
|
-
// header resolution failure), nothing has taken ownership of `
|
|
270
|
-
// so cancel it directly
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
audio.cancel(reason).catch(() => {});
|
|
370
|
+
// header resolution failure), nothing has taken ownership of `countedAudio`
|
|
371
|
+
// yet, so cancel it directly. When the model did take a reader, the cancel
|
|
372
|
+
// rejects and the model's cleanup owns it.
|
|
373
|
+
countedAudio.cancel(reason).catch(() => {});
|
|
274
374
|
transform.writable.abort(reason).catch(() => {
|
|
275
375
|
// the writable is already errored when the model stream failed mid-pipe
|
|
276
376
|
});
|
|
@@ -346,3 +446,54 @@ export function streamTranscribe({
|
|
|
346
446
|
},
|
|
347
447
|
};
|
|
348
448
|
}
|
|
449
|
+
|
|
450
|
+
function createByteCountingStream(
|
|
451
|
+
audio: ReadableStream<Uint8Array | string>,
|
|
452
|
+
onChunk: (byteLength: number) => void,
|
|
453
|
+
): ReadableStream<Uint8Array | string> {
|
|
454
|
+
let audioReader: ReadableStreamDefaultReader<Uint8Array | string> | undefined;
|
|
455
|
+
|
|
456
|
+
return new ReadableStream<Uint8Array | string>({
|
|
457
|
+
async pull(controller) {
|
|
458
|
+
audioReader ??= audio.getReader();
|
|
459
|
+
const { done, value } = await audioReader.read();
|
|
460
|
+
if (done) {
|
|
461
|
+
controller.close();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
onChunk(
|
|
466
|
+
typeof value === 'string'
|
|
467
|
+
? getBase64ByteLength(value)
|
|
468
|
+
: value.byteLength,
|
|
469
|
+
);
|
|
470
|
+
controller.enqueue(value);
|
|
471
|
+
},
|
|
472
|
+
async cancel(reason) {
|
|
473
|
+
if (audioReader != null) {
|
|
474
|
+
await audioReader.cancel(reason);
|
|
475
|
+
} else {
|
|
476
|
+
await audio.cancel(reason);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getBase64ByteLength(value: string): number {
|
|
483
|
+
let characterCount = 0;
|
|
484
|
+
let lastCharacter = '';
|
|
485
|
+
let secondLastCharacter = '';
|
|
486
|
+
|
|
487
|
+
for (const character of value) {
|
|
488
|
+
if (/\s/.test(character)) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
characterCount++;
|
|
492
|
+
secondLastCharacter = lastCharacter;
|
|
493
|
+
lastCharacter = character;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const padding =
|
|
497
|
+
lastCharacter === '=' ? (secondLastCharacter === '=' ? 2 : 1) : 0;
|
|
498
|
+
return Math.floor((characterCount * 3) / 4) - padding;
|
|
499
|
+
}
|