feat(media): transcription input, chunked transcripts and the Studio trigger
Groundwork for the transcribe operation and for creating Studio when it is first needed. - Contracts: TranscribeInputSchema (artifact id, optional language code). - transcribeChunks uploads audio chunks in order and stitches them into one transcript on the original timeline; transcriptionChunkSeconds sizes 16 kHz mono WAV chunks below the server's upload limit; transcriptSummary hands the bot the transcript, cut at a word. - ensureStudioWhenMediaConfigured creates Studio once a media server (generation or transcription) is configured. Local edits alone do not count, because ffmpeg and image tools are present on most installs and a specialist nobody asked for would appear for everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
fa97f39570
commit
5e0b09d9c1
102
packages/adapters/src/media/transcription.test.ts
Normal file
102
packages/adapters/src/media/transcription.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EmulatorTranscriptionProvider } from "../transcription-emulator.js";
|
||||
import {
|
||||
TRANSCRIPT_SUMMARY_MAX_CHARS,
|
||||
transcribeChunks,
|
||||
transcriptionChunkSeconds,
|
||||
transcriptSummary,
|
||||
} from "./transcription.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("transcriptionChunkSeconds", () => {
|
||||
it("keeps converted chunks below the upload limit, in whole minutes when long", () => {
|
||||
expect(transcriptionChunkSeconds(25 * 1024 * 1024)).toBe(720);
|
||||
expect(transcriptionChunkSeconds(25 * 1024 * 1024) * 32_000).toBeLessThan(25 * 1024 * 1024);
|
||||
expect(transcriptionChunkSeconds(2 * 1024 * 1024)).toBe(62);
|
||||
expect(transcriptionChunkSeconds(1024)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcribeChunks", () => {
|
||||
it("stitches chunks into one transcript on the original timeline", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "transcribe-chunks-"));
|
||||
dirs.push(dir);
|
||||
const first = join(dir, "chunk000.wav");
|
||||
const second = join(dir, "chunk001.wav");
|
||||
await writeFile(first, new Uint8Array([1, 2, 3]));
|
||||
await writeFile(second, new Uint8Array([4, 5, 6]));
|
||||
const progress: number[] = [];
|
||||
const transcript = await transcribeChunks(
|
||||
new EmulatorTranscriptionProvider(),
|
||||
[
|
||||
{ path: first, offsetMs: 0 },
|
||||
{ path: second, offsetMs: 600_000 },
|
||||
],
|
||||
{
|
||||
mimeType: "audio/wav",
|
||||
fileName: "talk.wav",
|
||||
signal: new AbortController().signal,
|
||||
onProgress: (fraction) => {
|
||||
progress.push(fraction);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(progress).toEqual([0.5, 1]);
|
||||
expect(transcript.language).toBe("en");
|
||||
expect(transcript.text).toBe(
|
||||
"Welcome to the studio. Today we cut a vertical clip. Welcome to the studio. Today we cut a vertical clip.",
|
||||
);
|
||||
expect(transcript.segments.map((segment) => [segment.startMs, segment.endMs])).toEqual([
|
||||
[0, 2400],
|
||||
[2400, 5100],
|
||||
[600_000, 602_400],
|
||||
[602_400, 605_100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops when the job is cancelled", async () => {
|
||||
await expect(
|
||||
transcribeChunks(new EmulatorTranscriptionProvider(), [{ path: "/missing", offsetMs: 0 }], {
|
||||
mimeType: "audio/wav",
|
||||
fileName: "talk.wav",
|
||||
signal: AbortSignal.abort(),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcriptSummary", () => {
|
||||
it("hands the bot the text and says what is attached", () => {
|
||||
expect(
|
||||
transcriptSummary("talk.mp4", {
|
||||
text: "Hello there.",
|
||||
segments: [{ startMs: 0, endMs: 900, text: "Hello there." }],
|
||||
}),
|
||||
).toBe("Transcript of talk.mp4: Hello there.\nSubtitles are attached as SRT and VTT.");
|
||||
expect(transcriptSummary("talk.mp4", { text: "Hi.", segments: [] })).toContain(
|
||||
"attached as text",
|
||||
);
|
||||
expect(transcriptSummary("silence.wav", { text: " ", segments: [] })).toBe(
|
||||
"No speech found in silence.wav.",
|
||||
);
|
||||
});
|
||||
|
||||
it("cuts a long transcript at a word", () => {
|
||||
const summary = transcriptSummary("talk.mp4", {
|
||||
text: "word ".repeat(1000),
|
||||
segments: [],
|
||||
});
|
||||
const body = summary.split("\n")[0]!;
|
||||
expect(body.length).toBeLessThanOrEqual(
|
||||
"Transcript of talk.mp4: ".length + TRANSCRIPT_SUMMARY_MAX_CHARS + 2,
|
||||
);
|
||||
expect(body.endsWith("word …")).toBe(true);
|
||||
});
|
||||
});
|
||||
79
packages/adapters/src/media/transcription.ts
Normal file
79
packages/adapters/src/media/transcription.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { Transcript, TranscriptionProvider, TranscriptSegment } from "@rakazo/adapter-kit";
|
||||
|
||||
/** Bytes per second of the 16 kHz mono 16-bit WAV that audio is converted to before upload. */
|
||||
export const TRANSCRIPTION_WAV_BYTES_PER_SECOND = 16_000 * 2;
|
||||
/** How much of the transcript the bot that started the job gets back. */
|
||||
export const TRANSCRIPT_SUMMARY_MAX_CHARS = 1200;
|
||||
|
||||
/**
|
||||
* Seconds of converted audio per upload, so each chunk stays below the server's size limit
|
||||
* with room for the WAV header. Rounded down to whole minutes when that is possible.
|
||||
*/
|
||||
export function transcriptionChunkSeconds(maxBytes: number): number {
|
||||
const seconds = Math.floor((maxBytes * 0.95) / TRANSCRIPTION_WAV_BYTES_PER_SECOND);
|
||||
return seconds >= 120 ? Math.floor(seconds / 60) * 60 : Math.max(10, seconds);
|
||||
}
|
||||
|
||||
export interface AudioChunk {
|
||||
path: string;
|
||||
/** Where the chunk starts in the original media. */
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribes audio chunks one after another and stitches them into one transcript with
|
||||
* segment times relative to the original media.
|
||||
*/
|
||||
export async function transcribeChunks(
|
||||
provider: TranscriptionProvider,
|
||||
chunks: readonly AudioChunk[],
|
||||
options: {
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
language?: string;
|
||||
signal: AbortSignal;
|
||||
onProgress?: (fraction: number) => Promise<void> | void;
|
||||
},
|
||||
): Promise<Transcript> {
|
||||
const texts: string[] = [];
|
||||
const segments: TranscriptSegment[] = [];
|
||||
let language: string | undefined;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
options.signal.throwIfAborted();
|
||||
const audio = new Uint8Array(await readFile(chunk.path));
|
||||
const part = await provider.transcribe({
|
||||
audio,
|
||||
mimeType: options.mimeType,
|
||||
fileName: options.fileName,
|
||||
...(options.language ? { language: options.language } : {}),
|
||||
signal: options.signal,
|
||||
});
|
||||
if (part.text) texts.push(part.text);
|
||||
language ??= part.language;
|
||||
for (const segment of part.segments) {
|
||||
segments.push({
|
||||
startMs: segment.startMs + chunk.offsetMs,
|
||||
endMs: segment.endMs + chunk.offsetMs,
|
||||
text: segment.text,
|
||||
});
|
||||
}
|
||||
await options.onProgress?.((index + 1) / chunks.length);
|
||||
}
|
||||
return { text: texts.join(" "), ...(language ? { language } : {}), segments };
|
||||
}
|
||||
|
||||
/** The transcript for the bot that started the job, cut at a word when it is long. */
|
||||
export function transcriptSummary(name: string, transcript: Transcript): string {
|
||||
const text = transcript.text.replace(/\s+/g, " ").trim();
|
||||
if (!text) return `No speech found in ${name}.`;
|
||||
const cut =
|
||||
text.length > TRANSCRIPT_SUMMARY_MAX_CHARS
|
||||
? `${text.slice(0, TRANSCRIPT_SUMMARY_MAX_CHARS).replace(/\s+\S*$/, "")} …`
|
||||
: text;
|
||||
const timing =
|
||||
transcript.segments.length > 0
|
||||
? "Subtitles are attached as SRT and VTT."
|
||||
: "The server returned no timings, so the transcript is attached as text.";
|
||||
return `Transcript of ${name}: ${cut}\n${timing}`;
|
||||
}
|
||||
57
packages/adapters/src/studio-specialist.test.ts
Normal file
57
packages/adapters/src/studio-specialist.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { MediaCapabilities } from "@rakazo/adapter-kit";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureStudioBot = vi.fn();
|
||||
vi.mock("@rakazo/db", () => ({
|
||||
ensureStudioBot: (...args: unknown[]) => ensureStudioBot(...args),
|
||||
}));
|
||||
|
||||
import { ensureStudioWhenMediaConfigured, mediaBackendsConfigured } from "./studio-specialist.js";
|
||||
|
||||
const none: MediaCapabilities = {
|
||||
generate_image: false,
|
||||
generate_video: false,
|
||||
edit_image: false,
|
||||
edit_video: false,
|
||||
export_video: false,
|
||||
transcribe: false,
|
||||
};
|
||||
const prisma = {} as PrismaClient;
|
||||
const run = { userId: "user-1", spaceId: "space-1" };
|
||||
|
||||
describe("Studio specialist", () => {
|
||||
beforeEach(() => {
|
||||
ensureStudioBot.mockReset();
|
||||
});
|
||||
|
||||
it("counts only media servers, not local edits, as media being set up", () => {
|
||||
expect(mediaBackendsConfigured({ ...none, edit_image: true, edit_video: true })).toBe(false);
|
||||
expect(mediaBackendsConfigured({ ...none, transcribe: true })).toBe(true);
|
||||
expect(mediaBackendsConfigured({ ...none, generate_image: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("creates Studio once a media server is configured", async () => {
|
||||
await ensureStudioWhenMediaConfigured(
|
||||
{ prisma, media: { capabilities: async () => ({ ...none, edit_video: true }) } },
|
||||
run,
|
||||
);
|
||||
await ensureStudioWhenMediaConfigured({ prisma }, run);
|
||||
expect(ensureStudioBot).not.toHaveBeenCalled();
|
||||
await ensureStudioWhenMediaConfigured(
|
||||
{ prisma, media: { capabilities: async () => ({ ...none, transcribe: true }) } },
|
||||
run,
|
||||
);
|
||||
expect(ensureStudioBot).toHaveBeenCalledWith(prisma, "user-1");
|
||||
});
|
||||
|
||||
it("never fails the run", async () => {
|
||||
ensureStudioBot.mockRejectedValue(new Error("database unavailable"));
|
||||
await expect(
|
||||
ensureStudioWhenMediaConfigured(
|
||||
{ prisma, media: { capabilities: async () => ({ ...none, generate_video: true }) } },
|
||||
run,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
28
packages/adapters/src/studio-specialist.ts
Normal file
28
packages/adapters/src/studio-specialist.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
|
||||
import { ensureStudioBot, type PrismaClient } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
|
||||
/**
|
||||
* Generation or transcription only run once the deployment owner set up a media server; local
|
||||
* edits (ffmpeg, image tools) are always there and alone do not call for a specialist.
|
||||
*/
|
||||
export function mediaBackendsConfigured(capabilities: MediaCapabilities): boolean {
|
||||
return capabilities.generate_image || capabilities.generate_video || capabilities.transcribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the user's Studio specialist the first time their main bot runs with a media server
|
||||
* configured, so the specialist directory lists it from that run on. Never fails the run.
|
||||
*/
|
||||
export async function ensureStudioWhenMediaConfigured(
|
||||
deps: { prisma: PrismaClient; media?: Pick<MediaRuntime, "capabilities"> },
|
||||
run: { userId: string; spaceId: string },
|
||||
): Promise<void> {
|
||||
if (!deps.media) return;
|
||||
try {
|
||||
if (!mediaBackendsConfigured(await deps.media.capabilities(run.spaceId))) return;
|
||||
await ensureStudioBot(deps.prisma, run.userId);
|
||||
} catch (error) {
|
||||
getLogger().error("studio specialist", error);
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ export * from "./integration-settings.js";
|
||||
export * from "./local-settings.js";
|
||||
export * from "./mcp.js";
|
||||
export * from "./media.js";
|
||||
export * from "./media-transcription.js";
|
||||
export * from "./model-tiers.js";
|
||||
export * from "./openai-compatible-ui.js";
|
||||
export * from "./project-goals.js";
|
||||
|
||||
13
packages/contracts/src/media-transcription.ts
Normal file
13
packages/contracts/src/media-transcription.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
|
||||
export const TranscribeInputSchema = z.object({
|
||||
artifactId: Id.describe("Audio or video artifact to transcribe."),
|
||||
language: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "Use a language code such as en or de")
|
||||
.optional()
|
||||
.describe("Spoken language as a code such as en or de; detected when omitted."),
|
||||
});
|
||||
export type TranscribeInput = z.infer<typeof TranscribeInputSchema>;
|
||||
Loading…
x
Reference in New Issue
Block a user