feat(server): support sliced audio (#11562)

fix AF-2479
This commit is contained in:
darkskygit
2025-04-09 08:21:19 +00:00
parent 06497773a7
commit 15abb78a6b
15 changed files with 239 additions and 75 deletions
@@ -88,3 +88,47 @@ Generated by [AVA](https://avajs.dev).
],
},
]
> should submit audio transcription job
[
{
status: 'running',
},
]
> should claim audio transcription job
[
{
status: 'claimed',
summary: '[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]',
title: '[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]',
transcription: [
{
end: '00:00:45',
speaker: 'A',
start: '00:00:30',
transcription: 'Hello, everyone.',
},
{
end: '00:01:10',
speaker: 'B',
start: '00:00:46',
transcription: 'Hi, thank you for joining the meeting today.',
},
{
end: '00:10:45',
speaker: 'A',
start: '00:10:30',
transcription: 'Hello, everyone.',
},
{
end: '00:11:10',
speaker: 'B',
start: '00:10:46',
transcription: 'Hi, thank you for joining the meeting today.',
},
],
},
]
@@ -883,34 +883,67 @@ test('should be able to transcript', async t => {
'[{"a":"A","s":30,"e":45,"t":"Hello, everyone."},{"a":"B","s":46,"e":70,"t":"Hi, thank you for joining the meeting today."}]'
);
const job = await submitAudioTranscription(
app,
workspaceId,
'blobId',
'test.mp3',
Buffer.from([1, 1])
);
t.snapshot(
cleanObject([job], ['id']),
'should submit audio transcription job'
);
t.truthy(job.id, 'should have job id');
// wait for processing
{
let { status } = (await audioTranscription(app, workspaceId, job.id)) || {};
const job = await submitAudioTranscription(app, workspaceId, '1', '1.mp3', [
Buffer.from([1, 1]),
]);
t.snapshot(
cleanObject([job], ['id']),
'should submit audio transcription job'
);
t.truthy(job.id, 'should have job id');
while (status !== 'finished') {
await new Promise(resolve => setTimeout(resolve, 1000));
({ status } = (await audioTranscription(app, workspaceId, job.id)) || {});
// wait for processing
{
let { status } =
(await audioTranscription(app, workspaceId, job.id)) || {};
while (status !== 'finished') {
await new Promise(resolve => setTimeout(resolve, 1000));
({ status } =
(await audioTranscription(app, workspaceId, job.id)) || {});
}
}
{
const result = await claimAudioTranscription(app, job.id);
t.snapshot(
cleanObject([result], ['id']),
'should claim audio transcription job'
);
}
}
{
const result = await claimAudioTranscription(app, job.id);
// sliced audio
const job = await submitAudioTranscription(app, workspaceId, '2', '2.mp3', [
Buffer.from([1, 1]),
Buffer.from([1, 2]),
]);
t.snapshot(
cleanObject([result], ['id']),
'should claim audio transcription job'
cleanObject([job], ['id']),
'should submit audio transcription job'
);
t.truthy(job.id, 'should have job id');
// wait for processing
{
let { status } =
(await audioTranscription(app, workspaceId, job.id)) || {};
while (status !== 'finished') {
await new Promise(resolve => setTimeout(resolve, 1000));
({ status } =
(await audioTranscription(app, workspaceId, job.id)) || {});
}
}
{
const result = await claimAudioTranscription(app, job.id);
t.snapshot(
cleanObject([result], ['id']),
'should claim audio transcription job'
);
}
}
});
@@ -335,17 +335,17 @@ export async function submitAudioTranscription(
workspaceId: string,
blobId: string,
fileName: string,
content: Buffer
content: Buffer[]
): Promise<{ id: string; status: string }> {
const res = await app
let resp = app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
query: `
mutation submitAudioTranscription($blob: Upload!, $blobId: String!, $workspaceId: String!) {
submitAudioTranscription(blob: $blob, blobId: $blobId, workspaceId: $workspaceId) {
mutation submitAudioTranscription($blob: Upload, $blobs: [Upload!], $blobId: String!, $workspaceId: String!) {
submitAudioTranscription(blob: $blob, blobs: $blobs, blobId: $blobId, workspaceId: $workspaceId) {
id
status
}
@@ -353,17 +353,29 @@ export async function submitAudioTranscription(
`,
variables: {
blob: null,
blobs: [],
blobId,
workspaceId,
},
})
)
.field('map', JSON.stringify({ '0': ['variables.blob'] }))
.attach('0', content, {
.field(
'map',
JSON.stringify(
Array.from<any>({ length: content.length }).reduce((acc, _, idx) => {
acc[idx.toString()] = [`variables.blobs.${idx}`];
return acc;
}, {})
)
);
for (const [idx, buffer] of content.entries()) {
resp = resp.attach(idx.toString(), buffer, {
filename: fileName,
contentType: 'application/octet-stream',
})
.expect(200);
});
}
const res = await resp.expect(200);
return res.body.data.submitAudioTranscription;
}
@@ -709,6 +709,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'bad_request',
message: () => `Transcription job not found.`,
},
copilot_transcription_audio_not_provided: {
type: 'bad_request',
message: () => `Audio not provided.`,
},
// Quota & Limit errors
blob_quota_exceeded: {
@@ -771,6 +771,12 @@ export class CopilotTranscriptionJobNotFound extends UserFriendlyError {
}
}
export class CopilotTranscriptionAudioNotProvided extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'copilot_transcription_audio_not_provided', message);
}
}
export class BlobQuotaExceeded extends UserFriendlyError {
constructor(message?: string) {
super('quota_exceeded', 'blob_quota_exceeded', message);
@@ -1027,6 +1033,7 @@ export enum ErrorNames {
COPILOT_EMBEDDING_UNAVAILABLE,
COPILOT_TRANSCRIPTION_JOB_EXISTS,
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND,
COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED,
BLOB_QUOTA_EXCEEDED,
STORAGE_QUOTA_EXCEEDED,
MEMBER_QUOTA_EXCEEDED,
@@ -14,6 +14,7 @@ import { AiJobStatus } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import {
CopilotTranscriptionAudioNotProvided,
CopilotTranscriptionJobNotFound,
type FileUpload,
} from '../../../base';
@@ -100,20 +101,27 @@ export class CopilotTranscriptionResolver {
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('blobId') blobId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
@Args({ name: 'blob', type: () => GraphQLUpload, nullable: true })
blob: FileUpload | null,
@Args({ name: 'blobs', type: () => [GraphQLUpload], nullable: true })
blobs: FileUpload[] | null
): Promise<TranscriptionResultType | null> {
await this.ac
.user(user.id)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
// merge blobs
const allBlobs = blob ? [blob, ...(blobs || [])].filter(v => !!v) : blobs;
if (!allBlobs || allBlobs.length === 0) {
throw new CopilotTranscriptionAudioNotProvided();
}
const jobResult = await this.service.submitTranscriptionJob(
user.id,
workspaceId,
blobId,
blob
await Promise.all(allBlobs)
);
return this.handleJobResult(jobResult);
@@ -136,14 +144,13 @@ export class CopilotTranscriptionResolver {
workspaceId,
jobId
);
if (!job || !job.url || !job.mimeType) {
if (!job || !job.infos) {
throw new CopilotTranscriptionJobNotFound();
}
const jobResult = await this.service.executeTranscriptionJob(
job.id,
job.url,
job.mimeType
job.infos
);
return this.handleJobResult(jobResult);
@@ -23,6 +23,7 @@ import {
} from '../providers';
import { CopilotStorage } from '../storage';
import {
AudioBlobInfos,
TranscriptionPayload,
TranscriptionResponseSchema,
TranscriptPayloadSchema,
@@ -32,8 +33,7 @@ import { readStream } from './utils';
export type TranscriptionJob = {
id: string;
status: AiJobStatus;
url?: string;
mimeType?: string;
infos?: AudioBlobInfos;
transcription?: TranscriptionPayload;
};
@@ -52,7 +52,7 @@ export class CopilotTranscriptionService {
userId: string,
workspaceId: string,
blobId: string,
blob: FileUpload
blobs: FileUpload[]
): Promise<TranscriptionJob> {
if (await this.models.copilotJob.has(userId, workspaceId, blobId)) {
throw new CopilotTranscriptionJobExists();
@@ -65,21 +65,24 @@ export class CopilotTranscriptionService {
type: AiJobType.transcription,
});
const buffer = await readStream(blob.createReadStream());
const url = await this.storage.put(userId, workspaceId, blobId, buffer);
const infos: AudioBlobInfos = [];
for (const blob of blobs) {
const buffer = await readStream(blob.createReadStream());
const url = await this.storage.put(userId, workspaceId, blobId, buffer);
infos.push({ url, mimeType: blob.mimetype });
}
return await this.executeTranscriptionJob(jobId, url, blob.mimetype);
return await this.executeTranscriptionJob(jobId, infos);
}
async executeTranscriptionJob(
jobId: string,
url: string,
mimeType: string
infos: AudioBlobInfos
): Promise<TranscriptionJob> {
const status = AiJobStatus.running;
const success = await this.models.copilotJob.update(jobId, {
status,
payload: { url, mimeType },
payload: { infos },
});
if (!success) {
@@ -88,8 +91,7 @@ export class CopilotTranscriptionService {
await this.job.add('copilot.transcript.submit', {
jobId,
url,
mimeType,
infos,
});
return { id: jobId, status };
@@ -132,8 +134,13 @@ export class CopilotTranscriptionService {
const payload = TranscriptPayloadSchema.safeParse(job.payload);
if (payload.success) {
ret.url = payload.data.url || undefined;
ret.mimeType = payload.data.mimeType || undefined;
let { url, mimeType, infos } = payload.data;
infos = infos || [];
if (url && mimeType) {
infos.push({ url, mimeType });
}
ret.infos = this.mergeInfos(infos, url, mimeType);
if (job.status === AiJobStatus.claimed) {
ret.transcription = payload.data;
}
@@ -173,7 +180,24 @@ export class CopilotTranscriptionService {
);
}
private convertTime(time: number) {
// TODO(@darkskygit): remove after old server down
private mergeInfos(
infos?: AudioBlobInfos | null,
url?: string | null,
mimeType?: string | null
) {
if (url && mimeType) {
if (infos) {
infos.push({ url, mimeType });
} else {
infos = [{ url, mimeType }];
}
}
return infos || [];
}
private convertTime(time: number, offset = 0) {
time = time + offset;
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
const hours = Math.floor(minutes / 60);
@@ -186,29 +210,38 @@ export class CopilotTranscriptionService {
@OnJob('copilot.transcript.submit')
async transcriptAudio({
jobId,
infos,
// @deprecated
url,
mimeType,
}: Jobs['copilot.transcript.submit']) {
try {
const result = await this.chatWithPrompt(
'Transcript audio',
{
attachments: [url],
params: { mimetype: mimeType },
},
TranscriptionResponseSchema
);
const blobInfos = this.mergeInfos(infos, url, mimeType);
const transcriptions = [];
for (const [idx, { url, mimeType }] of blobInfos.entries()) {
const result = await this.chatWithPrompt(
'Transcript audio',
{
attachments: [url],
params: { mimetype: mimeType },
},
TranscriptionResponseSchema
);
const offset = idx * 10 * 60;
const transcription = TranscriptionResponseSchema.parse(
JSON.parse(result)
).map(t => ({
speaker: t.a,
start: this.convertTime(t.s, offset),
end: this.convertTime(t.e, offset),
transcription: t.t,
}));
transcriptions.push(transcription);
}
const transcription = TranscriptionResponseSchema.parse(
JSON.parse(result)
).map(t => ({
speaker: t.a,
start: this.convertTime(t.s),
end: this.convertTime(t.e),
transcription: t.t,
}));
await this.models.copilotJob.update(jobId, {
payload: { transcription },
payload: { transcription: transcriptions.flat() },
});
await this.job.add('copilot.transcript.summary.submit', {
@@ -20,9 +20,17 @@ const TranscriptionItemSchema = z.object({
export const TranscriptionSchema = z.array(TranscriptionItemSchema);
export const AudioBlobInfosSchema = z
.object({
url: z.string(),
mimeType: z.string(),
})
.array();
export const TranscriptPayloadSchema = z.object({
url: z.string().nullable().optional(),
mimeType: z.string().nullable().optional(),
infos: AudioBlobInfosSchema.nullable().optional(),
title: z.string().nullable().optional(),
summary: z.string().nullable().optional(),
transcription: TranscriptionSchema.nullable().optional(),
@@ -32,6 +40,8 @@ export type TranscriptionItem = z.infer<typeof TranscriptionItemSchema>;
export type Transcription = z.infer<typeof TranscriptionSchema>;
export type TranscriptionPayload = z.infer<typeof TranscriptPayloadSchema>;
export type AudioBlobInfos = z.infer<typeof AudioBlobInfosSchema>;
declare global {
interface Events {
'workspace.file.transcript.finished': {
@@ -44,8 +54,11 @@ declare global {
interface Jobs {
'copilot.transcript.submit': {
jobId: string;
url: string;
mimeType: string;
infos?: AudioBlobInfos;
/// @deprecated use `infos` instead
url?: string;
/// @deprecated use `infos` instead
mimeType?: string;
};
'copilot.transcript.summary.submit': {
jobId: string;
+2 -1
View File
@@ -435,6 +435,7 @@ enum ErrorNames {
COPILOT_QUOTA_EXCEEDED
COPILOT_SESSION_DELETED
COPILOT_SESSION_NOT_FOUND
COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED
COPILOT_TRANSCRIPTION_JOB_EXISTS
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND
CUSTOMER_PORTAL_CREATE_FAILED
@@ -1004,7 +1005,7 @@ type Mutation {
sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean!
sendVerifyEmail(callbackUrl: String!): Boolean!
setBlob(blob: Upload!, workspaceId: String!): String!
submitAudioTranscription(blob: Upload!, blobId: String!, workspaceId: String!): TranscriptionResultType
submitAudioTranscription(blob: Upload, blobId: String!, blobs: [Upload!], workspaceId: String!): TranscriptionResultType
"""update app configuration"""
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
@@ -1,5 +1,5 @@
mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload!) {
submitAudioTranscription(blob: $blob, blobId: $blobId, workspaceId: $workspaceId) {
mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload, $blobs: [Upload!]) {
submitAudioTranscription(blob: $blob, blobs: $blobs, blobId: $blobId, workspaceId: $workspaceId) {
id
status
}
+2 -2
View File
@@ -601,9 +601,10 @@ export const getCopilotHistoriesQuery = {
export const submitAudioTranscriptionMutation = {
id: 'submitAudioTranscriptionMutation' as const,
op: 'submitAudioTranscription',
query: `mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload!) {
query: `mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload, $blobs: [Upload!]) {
submitAudioTranscription(
blob: $blob
blobs: $blobs
blobId: $blobId
workspaceId: $workspaceId
) {
@@ -611,7 +612,6 @@ export const submitAudioTranscriptionMutation = {
status
}
}`,
file: true,
};
export const claimAudioTranscriptionMutation = {
+7 -2
View File
@@ -580,6 +580,7 @@ export enum ErrorNames {
COPILOT_QUOTA_EXCEEDED = 'COPILOT_QUOTA_EXCEEDED',
COPILOT_SESSION_DELETED = 'COPILOT_SESSION_DELETED',
COPILOT_SESSION_NOT_FOUND = 'COPILOT_SESSION_NOT_FOUND',
COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED = 'COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED',
COPILOT_TRANSCRIPTION_JOB_EXISTS = 'COPILOT_TRANSCRIPTION_JOB_EXISTS',
COPILOT_TRANSCRIPTION_JOB_NOT_FOUND = 'COPILOT_TRANSCRIPTION_JOB_NOT_FOUND',
CUSTOMER_PORTAL_CREATE_FAILED = 'CUSTOMER_PORTAL_CREATE_FAILED',
@@ -1432,8 +1433,9 @@ export interface MutationSetBlobArgs {
}
export interface MutationSubmitAudioTranscriptionArgs {
blob: Scalars['Upload']['input'];
blob?: InputMaybe<Scalars['Upload']['input']>;
blobId: Scalars['String']['input'];
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
workspaceId: Scalars['String']['input'];
}
@@ -2991,7 +2993,10 @@ export type GetCopilotHistoriesQuery = {
export type SubmitAudioTranscriptionMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
blobId: Scalars['String']['input'];
blob: Scalars['Upload']['input'];
blob?: InputMaybe<Scalars['Upload']['input']>;
blobs?: InputMaybe<
Array<Scalars['Upload']['input']> | Scalars['Upload']['input']
>;
}>;
export type SubmitAudioTranscriptionMutation = {
+4
View File
@@ -8127,6 +8127,10 @@ export function useAFFiNEI18N(): {
* `Transcription job not found.`
*/
["error.COPILOT_TRANSCRIPTION_JOB_NOT_FOUND"](): string;
/**
* `Audio not provided.`
*/
["error.COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED"](): string;
/**
* `You have exceeded your blob size quota.`
*/
@@ -2010,6 +2010,7 @@
"error.COPILOT_EMBEDDING_UNAVAILABLE": "Embedding feature not available, you may need to install pgvector extension to your database",
"error.COPILOT_TRANSCRIPTION_JOB_EXISTS": "Transcription job already exists",
"error.COPILOT_TRANSCRIPTION_JOB_NOT_FOUND": "Transcription job not found.",
"error.COPILOT_TRANSCRIPTION_AUDIO_NOT_PROVIDED": "Audio not provided.",
"error.BLOB_QUOTA_EXCEEDED": "You have exceeded your blob size quota.",
"error.STORAGE_QUOTA_EXCEEDED": "You have exceeded your storage quota.",
"error.MEMBER_QUOTA_EXCEEDED": "You have exceeded your workspace member quota.",