@athanlab/mcp 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +222 -2
- package/dist/api/client.js +282 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.js +284 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/features.js +123 -0
- package/dist/api/features.js.map +1 -0
- package/dist/api/types.js +15 -0
- package/dist/api/types.js.map +1 -0
- package/dist/batch.js +394 -0
- package/dist/batch.js.map +1 -0
- package/dist/clock.js +31 -0
- package/dist/clock.js.map +1 -0
- package/dist/config.js +101 -0
- package/dist/config.js.map +1 -0
- package/dist/files.js +167 -0
- package/dist/files.js.map +1 -0
- package/dist/guide.js +96 -0
- package/dist/guide.js.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.js +158 -0
- package/dist/lock.js.map +1 -0
- package/dist/log.js +50 -0
- package/dist/log.js.map +1 -0
- package/dist/script.js +869 -0
- package/dist/script.js.map +1 -0
- package/dist/server.js +76 -0
- package/dist/server.js.map +1 -0
- package/dist/speak.js +95 -0
- package/dist/speak.js.map +1 -0
- package/dist/speech.js +230 -0
- package/dist/speech.js.map +1 -0
- package/dist/timeline.js +37 -0
- package/dist/timeline.js.map +1 -0
- package/dist/tools.js +617 -0
- package/dist/tools.js.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/dist/wav.js +169 -0
- package/dist/wav.js.map +1 -0
- package/package.json +51 -4
- package/skill/athanlab-voice/SKILL.md +166 -0
package/dist/script.js
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* athanlab_speak_script's engine: many lines → one file per line, a
|
|
3
|
+
* manifest.json on ONE timeline, and optionally combined.wav.
|
|
4
|
+
*
|
|
5
|
+
* 0. Lock: one run per script folder at a time (src/lock.ts); a second run
|
|
6
|
+
* on the same folder is refused with script_running.
|
|
7
|
+
* 1. Plan: a line whose file AND matching manifest entry exist is skipped;
|
|
8
|
+
* a line with a job_id in the manifest is resumed (polled, downloaded —
|
|
9
|
+
* never paid again); everything else is created. Each resumed job is
|
|
10
|
+
* read first: one that is gone, failed, or past its 30-day audio
|
|
11
|
+
* retention is planned as a new line, so it is capped, quoted and
|
|
12
|
+
* confirm-gated like one. The run phase never creates a job for a line
|
|
13
|
+
* it only follows.
|
|
14
|
+
* 2. Quote: a dry run per new line (free), summed; what each line costs is
|
|
15
|
+
* cached for the confirm: true call, the balance never is. GET /usage
|
|
16
|
+
* gives the balance as it is now, and refuses a read-only key or an
|
|
17
|
+
* exhausted key budget before anything is asked of the user. Above the
|
|
18
|
+
* confirmation threshold (2,000 characters or more than 20 lines) the
|
|
19
|
+
* call stops here and returns the quote unless confirm is true.
|
|
20
|
+
* 3. Run: the same /usage read sets the pool size (free slots of
|
|
21
|
+
* max_concurrent_jobs, at least 1); each line is its own job with its
|
|
22
|
+
* own Idempotency-Key, written to the manifest BEFORE the POST — so even
|
|
23
|
+
* a crash between the POST and its answer resumes with the same key and
|
|
24
|
+
* is never charged twice. A stop error (plan, balance, key budget, auth…) stops new jobs;
|
|
25
|
+
* lines already running finish.
|
|
26
|
+
* 4. Finish: durations (exact, from the WAV header, for WAV), the timeline
|
|
27
|
+
* (gaps.sentence within a speaker, gaps.turn at a speaker change), the
|
|
28
|
+
* manifest, and combined.wav when asked and every line succeeded.
|
|
29
|
+
*
|
|
30
|
+
* When the API documents POST /speech/batch, new lines are created through
|
|
31
|
+
* one batch instead (src/batch.ts); everything else is the same.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { readFile } from 'node:fs/promises';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { ACCOUNT_STOP_CODES, AthanLabError, LINE_ERROR_CODES, isRecord, toAthanLabError } from './api/errors.js';
|
|
37
|
+
import { ensureDir, exists, resolveInside, sanitizeDirname, sanitizeFilename, writeFileAtomic } from './files.js';
|
|
38
|
+
import { acquireScriptLock } from './lock.js';
|
|
39
|
+
import { JOB_ID_PATTERN, TEXT_MAX_CHARACTERS, createSpeechJob, downloadJobAudio, getJob, jobOutcomeError, quoteSpeech, timingOf, waitForJob, } from './speech.js';
|
|
40
|
+
import { DEFAULT_GAPS, layoutTimeline, roundMs } from './timeline.js';
|
|
41
|
+
import { combineWavFiles, readWavInfo } from './wav.js';
|
|
42
|
+
import { VERSION } from './version.js';
|
|
43
|
+
export const CONFIRM_CHARACTERS_THRESHOLD = 2000;
|
|
44
|
+
export const CONFIRM_LINES_THRESHOLD = 20;
|
|
45
|
+
export const MAX_SCRIPT_LINES = 200;
|
|
46
|
+
export const DEFAULT_SCRIPT_TIMEOUT_SECONDS = 900;
|
|
47
|
+
const MAX_DOWNLOADS_AT_ONCE = 4;
|
|
48
|
+
const QUOTE_CONCURRENCY = 3;
|
|
49
|
+
const PROBE_CONCURRENCY = 4;
|
|
50
|
+
const QUOTE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
51
|
+
const MAX_JOB_ATTEMPTS = 2;
|
|
52
|
+
export const MANIFEST_FILE = 'manifest.json';
|
|
53
|
+
export const COMBINED_FILE = 'combined.wav';
|
|
54
|
+
/** A succeeded job whose audio can still be downloaded (the API sends audio: null once it is deleted). */
|
|
55
|
+
function audioAvailable(job, nowMs) {
|
|
56
|
+
if (!job.audio)
|
|
57
|
+
return false;
|
|
58
|
+
const expires = Date.parse(job.audio.expires_at);
|
|
59
|
+
return !Number.isFinite(expires) || expires > nowMs;
|
|
60
|
+
}
|
|
61
|
+
function sha256(value) {
|
|
62
|
+
return createHash('sha256').update(value).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
function emptyToNull(value) {
|
|
65
|
+
const trimmed = value?.trim();
|
|
66
|
+
return trimmed ? trimmed : null;
|
|
67
|
+
}
|
|
68
|
+
function padWidth(count) {
|
|
69
|
+
return Math.max(3, String(count).length);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `NNN-label.ext`. The label's path separators become '-' BEFORE the position
|
|
73
|
+
* prefix is added: sanitizeFilename keeps only a name's last path segment, so
|
|
74
|
+
* "001-Mom/Dad" would lose its prefix and every "Mom/Dad" line would share
|
|
75
|
+
* "Dad.wav". With the prefix always intact, no two lines share a file.
|
|
76
|
+
*/
|
|
77
|
+
export function lineFileName(index, label, count, ext) {
|
|
78
|
+
const prefix = String(index + 1).padStart(padWidth(count), '0');
|
|
79
|
+
const clean = label ? label.replace(/[\\/]+/g, '-') : '';
|
|
80
|
+
return sanitizeFilename(clean ? `${prefix}-${clean}` : prefix, ext, prefix);
|
|
81
|
+
}
|
|
82
|
+
async function loadManifest(file, ctx) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
85
|
+
if (isRecord(parsed) && parsed.version === 1 && Array.isArray(parsed.lines))
|
|
86
|
+
return parsed;
|
|
87
|
+
ctx.logger.warn('ignoring an unreadable manifest.json', { file });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err.code !== 'ENOENT')
|
|
91
|
+
ctx.logger.warn('ignoring an unreadable manifest.json', { file });
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** Pool of `size` workers over `items`; stops taking new items once `shouldStop()` is true. */
|
|
96
|
+
async function pool(items, size, shouldStop, work) {
|
|
97
|
+
let next = 0;
|
|
98
|
+
const workers = Array.from({ length: Math.max(1, Math.min(size, items.length)) }, async () => {
|
|
99
|
+
while (!shouldStop() && next < items.length) {
|
|
100
|
+
const item = items[next++];
|
|
101
|
+
await work(item);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
await Promise.all(workers);
|
|
105
|
+
}
|
|
106
|
+
class Semaphore {
|
|
107
|
+
limit;
|
|
108
|
+
active = 0;
|
|
109
|
+
waiting = [];
|
|
110
|
+
constructor(limit) {
|
|
111
|
+
this.limit = limit;
|
|
112
|
+
}
|
|
113
|
+
async run(fn) {
|
|
114
|
+
if (this.active >= this.limit)
|
|
115
|
+
await new Promise(resolve => this.waiting.push(resolve));
|
|
116
|
+
this.active++;
|
|
117
|
+
try {
|
|
118
|
+
return await fn();
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
this.active--;
|
|
122
|
+
this.waiting.shift()?.();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function isStringRecord(value) {
|
|
127
|
+
return isRecord(value) && Object.values(value).every(v => typeof v === 'string');
|
|
128
|
+
}
|
|
129
|
+
/** Does this job carry this line's request? (A job resource has no text: its length, voice, format and number mode.) */
|
|
130
|
+
function jobMatchesLine(job, entry, format) {
|
|
131
|
+
const input = job.input;
|
|
132
|
+
return !!input
|
|
133
|
+
&& input.characters === entry.characters
|
|
134
|
+
&& input.output_format === format
|
|
135
|
+
&& (input.number_mode ?? 'smart') === (entry.number_mode ?? 'smart')
|
|
136
|
+
&& (entry.requested_voice_id === null || input.voice_id === entry.requested_voice_id);
|
|
137
|
+
}
|
|
138
|
+
function lineMetadata(scriptDir, entry) {
|
|
139
|
+
const metadata = {
|
|
140
|
+
client: 'athanlab-mcp',
|
|
141
|
+
script: scriptDir.slice(0, 500),
|
|
142
|
+
line_index: String(entry.index),
|
|
143
|
+
};
|
|
144
|
+
if (entry.id)
|
|
145
|
+
metadata.line_id = entry.id.slice(0, 500);
|
|
146
|
+
return metadata;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The same audio: text, voice and number mode. The file name is not part of
|
|
150
|
+
* it — a renamed line (new id, or a name from an older version of this
|
|
151
|
+
* server) keeps its job and is downloaded again under the new name, free.
|
|
152
|
+
*/
|
|
153
|
+
function sameRequest(previous, next) {
|
|
154
|
+
return !!previous
|
|
155
|
+
&& previous.text === next.text
|
|
156
|
+
&& (previous.requested_voice_id ?? null) === next.requested_voice_id
|
|
157
|
+
&& (previous.number_mode ?? null) === next.number_mode;
|
|
158
|
+
}
|
|
159
|
+
export async function runScript(ctx, input, opts = {}) {
|
|
160
|
+
ctx.client.assertKey();
|
|
161
|
+
const { lines } = input;
|
|
162
|
+
if (lines.length === 0 || lines.length > MAX_SCRIPT_LINES) {
|
|
163
|
+
throw AthanLabError.client('invalid_input', `A script has 1–${MAX_SCRIPT_LINES} lines; this one has ${lines.length}.`, { param: 'lines' });
|
|
164
|
+
}
|
|
165
|
+
if (input.combine && input.output_format !== 'wav') {
|
|
166
|
+
throw AthanLabError.client('invalid_input', 'combine: true needs output_format "wav" (lines are joined as PCM). Use wav, or combine the MP3 files yourself from manifest.json.', { param: 'combine' });
|
|
167
|
+
}
|
|
168
|
+
lines.forEach((line, index) => {
|
|
169
|
+
if (!line.text.trim())
|
|
170
|
+
throw AthanLabError.client('invalid_input', `Line ${index} has no text.`, { param: `lines.${index}.text` });
|
|
171
|
+
if (line.text.length > TEXT_MAX_CHARACTERS) {
|
|
172
|
+
throw AthanLabError.client('text_too_long', `Line ${index} is ${line.text.length} characters; a line takes at most ${TEXT_MAX_CHARACTERS}. Split it at sentence ends (။ ! ?).`, { param: `lines.${index}.text` });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
const gaps = {
|
|
176
|
+
sentence: input.gaps?.sentence ?? DEFAULT_GAPS.sentence,
|
|
177
|
+
turn: input.gaps?.turn ?? DEFAULT_GAPS.turn,
|
|
178
|
+
};
|
|
179
|
+
const format = input.output_format;
|
|
180
|
+
const numberMode = input.number_mode ?? null;
|
|
181
|
+
// A voices map is a casting decision: a speaker it does not name (a typo, a
|
|
182
|
+
// trailing space) would otherwise be voiced — and charged — in the default
|
|
183
|
+
// voice. Own properties only, so a speaker called 'constructor' is just a name.
|
|
184
|
+
const voicesMap = input.voices ?? null;
|
|
185
|
+
const castVoice = (speaker) => voicesMap && Object.hasOwn(voicesMap, speaker) ? emptyToNull(voicesMap[speaker]) : null;
|
|
186
|
+
if (voicesMap && Object.keys(voicesMap).length > 0) {
|
|
187
|
+
const uncast = [...new Set(lines
|
|
188
|
+
.filter(line => !emptyToNull(line.voice_id) && emptyToNull(line.speaker) && !Object.hasOwn(voicesMap, emptyToNull(line.speaker)))
|
|
189
|
+
.map(line => emptyToNull(line.speaker)))];
|
|
190
|
+
if (uncast.length) {
|
|
191
|
+
throw AthanLabError.client('invalid_input', `These speakers have no entry in voices and no voice_id: ${uncast.map(s => JSON.stringify(s)).join(', ')}. Add them to voices (names must match exactly) or give those lines a voice_id. Nothing was sent.`, { param: 'voices' });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const drafts = lines.map((line, index) => {
|
|
195
|
+
const id = emptyToNull(line.id);
|
|
196
|
+
const speaker = emptyToNull(line.speaker);
|
|
197
|
+
const requested = emptyToNull(line.voice_id) ?? (speaker ? castVoice(speaker) : null);
|
|
198
|
+
return {
|
|
199
|
+
index,
|
|
200
|
+
id,
|
|
201
|
+
speaker,
|
|
202
|
+
voice_id: requested,
|
|
203
|
+
requested_voice_id: requested,
|
|
204
|
+
number_mode: numberMode,
|
|
205
|
+
text: line.text,
|
|
206
|
+
file: lineFileName(index, id ?? speaker, lines.length, format),
|
|
207
|
+
status: 'pending',
|
|
208
|
+
job_id: null,
|
|
209
|
+
idempotency_key: null,
|
|
210
|
+
request_metadata: null,
|
|
211
|
+
attempts: 0,
|
|
212
|
+
characters: line.text.length,
|
|
213
|
+
characters_charged: null,
|
|
214
|
+
duration_seconds: null,
|
|
215
|
+
start_seconds: null,
|
|
216
|
+
end_seconds: null,
|
|
217
|
+
speech_start_seconds: null,
|
|
218
|
+
speech_end_seconds: null,
|
|
219
|
+
error: null,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
if (new Set(drafts.map(d => d.file)).size !== drafts.length) {
|
|
223
|
+
// Unreachable while every name starts with its line's position; a guard, because two lines on one file would silently overwrite paid audio.
|
|
224
|
+
throw AthanLabError.client('internal_client_error', 'Two script lines would be written to the same file, so nothing was sent. Please report this.');
|
|
225
|
+
}
|
|
226
|
+
const scriptId = sha256(JSON.stringify({
|
|
227
|
+
format,
|
|
228
|
+
numberMode,
|
|
229
|
+
lines: drafts.map(d => [d.id, d.speaker, d.requested_voice_id, d.text]),
|
|
230
|
+
})).slice(0, 12);
|
|
231
|
+
const subdir = input.out_subdir ? sanitizeDirname(input.out_subdir, `script-${scriptId}`) : `script-${scriptId}`;
|
|
232
|
+
const dir = resolveInside(ctx.config.outputDir, subdir);
|
|
233
|
+
// One run per folder: a second one would plan and pay for the same lines again.
|
|
234
|
+
await ensureDir(ctx.config.outputDir);
|
|
235
|
+
const lock = await acquireScriptLock(dir, resolveInside(ctx.config.outputDir, `.${subdir}.lock`));
|
|
236
|
+
try {
|
|
237
|
+
return await runLocked(ctx, input, opts, { drafts, gaps, format, numberMode, scriptId, subdir, dir });
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
await lock.release();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function runLocked(ctx, input, opts, base) {
|
|
244
|
+
const { drafts, gaps, format, numberMode, scriptId, subdir, dir } = base;
|
|
245
|
+
const manifestPath = path.join(dir, MANIFEST_FILE);
|
|
246
|
+
const previous = await loadManifest(manifestPath, ctx);
|
|
247
|
+
const previousByIndex = new Map((previous?.lines ?? []).map(line => [line.index, line]));
|
|
248
|
+
const formatUnchanged = previous?.output_format === format;
|
|
249
|
+
// ── 1. Plan ──
|
|
250
|
+
const planned = [];
|
|
251
|
+
for (const draft of drafts) {
|
|
252
|
+
const old = formatUnchanged ? previousByIndex.get(draft.index) : undefined;
|
|
253
|
+
let entry = draft;
|
|
254
|
+
let action = 'create';
|
|
255
|
+
const carried = sameRequest(old, draft);
|
|
256
|
+
if (carried) {
|
|
257
|
+
entry = { ...draft, ...old, index: draft.index, id: draft.id, speaker: draft.speaker, file: draft.file, error: null };
|
|
258
|
+
if (old.status === 'succeeded' && old.file === draft.file && await exists(path.join(dir, draft.file))) {
|
|
259
|
+
action = 'skip';
|
|
260
|
+
}
|
|
261
|
+
else if (old.job_id && (old.status === 'processing' || old.status === 'succeeded')) {
|
|
262
|
+
action = 'resume';
|
|
263
|
+
}
|
|
264
|
+
else if (old.status === 'pending' && old.idempotency_key) {
|
|
265
|
+
// A POST may have landed without its answer: same key → the same job,
|
|
266
|
+
// never a second charge — provided the body is the very same, so the
|
|
267
|
+
// metadata it was first sent with (older manifests: rebuilt from that
|
|
268
|
+
// run's line id), even if the line has been renamed since.
|
|
269
|
+
action = 'create';
|
|
270
|
+
entry.request_metadata = isStringRecord(old.request_metadata) ? old.request_metadata : lineMetadata(subdir, old);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
entry = { ...entry, status: 'pending', job_id: null, idempotency_key: null, request_metadata: null, error: null };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
planned.push({
|
|
277
|
+
entry,
|
|
278
|
+
action,
|
|
279
|
+
carried,
|
|
280
|
+
input: {
|
|
281
|
+
text: entry.text,
|
|
282
|
+
voice_id: entry.requested_voice_id ?? undefined,
|
|
283
|
+
output_format: format,
|
|
284
|
+
number_mode: numberMode ?? undefined,
|
|
285
|
+
metadata: action === 'create' && entry.idempotency_key && entry.request_metadata ? entry.request_metadata : lineMetadata(subdir, entry),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** A new job for this line: no job, no key, metadata from the line as it is now. */
|
|
290
|
+
const resetForNewJob = (p) => {
|
|
291
|
+
Object.assign(p.entry, { status: 'pending', job_id: null, idempotency_key: null, request_metadata: null });
|
|
292
|
+
p.input = { ...p.input, metadata: lineMetadata(subdir, p.entry) };
|
|
293
|
+
};
|
|
294
|
+
const recreate = (p, reason) => {
|
|
295
|
+
resetForNewJob(p);
|
|
296
|
+
p.entry.error = null;
|
|
297
|
+
p.action = 'create';
|
|
298
|
+
p.recreated = reason;
|
|
299
|
+
};
|
|
300
|
+
// ── 1b. Probe the jobs of resumed lines ──
|
|
301
|
+
// A job from an earlier run may be gone, failed, or past its 30-day audio
|
|
302
|
+
// retention. Such a line needs a NEW job — a new charge — so it is found
|
|
303
|
+
// here, before the cap, the quote and the confirmation, and goes through
|
|
304
|
+
// them like any new line. The run phase never creates a job for a line it
|
|
305
|
+
// was only asked to follow.
|
|
306
|
+
const probing = planned.filter(p => p.action === 'resume');
|
|
307
|
+
if (probing.length > 0) {
|
|
308
|
+
opts.onProgress?.(0, planned.length, `Checking ${probing.length} job(s) from an earlier run`);
|
|
309
|
+
await pool(probing, PROBE_CONCURRENCY, () => false, async (p) => {
|
|
310
|
+
let job;
|
|
311
|
+
try {
|
|
312
|
+
job = await getJob(ctx, p.entry.job_id, opts.signal ? { signal: opts.signal } : {});
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
const e = toAthanLabError(err);
|
|
316
|
+
if (e.code !== 'not_found')
|
|
317
|
+
throw e;
|
|
318
|
+
recreate(p, 'not_found');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (job.status === 'failed' || job.status === 'cancelled')
|
|
322
|
+
recreate(p, job.status === 'failed' ? 'job_failed' : 'cancelled');
|
|
323
|
+
else if (job.status === 'succeeded' && !audioAvailable(job, ctx.clock.now()))
|
|
324
|
+
recreate(p, 'audio_expired');
|
|
325
|
+
else
|
|
326
|
+
p.probed = job;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const toCreate = planned.filter(p => p.action === 'create');
|
|
330
|
+
const toResume = planned.filter(p => p.action === 'resume');
|
|
331
|
+
const skipped = planned.filter(p => p.action === 'skip');
|
|
332
|
+
const recreated = toCreate.filter(p => p.recreated);
|
|
333
|
+
const createCharacters = toCreate.reduce((sum, p) => sum + p.entry.characters, 0);
|
|
334
|
+
const recreatedNote = recreated.length > 0
|
|
335
|
+
? ` This includes ${recreated.length} line(s) whose earlier job can no longer deliver audio (deleted after 30 days, gone, or failed), so they are generated — and charged — again.`
|
|
336
|
+
: '';
|
|
337
|
+
if (ctx.config.maxCharsPerCall !== null && createCharacters > ctx.config.maxCharsPerCall) {
|
|
338
|
+
throw AthanLabError.client('max_chars_exceeded', `This script would generate ${createCharacters} characters in one call, above ATHANLAB_MAX_CHARS_PER_CALL (${ctx.config.maxCharsPerCall}). Nothing was sent.${recreatedNote} Split the script into smaller calls (same out_subdir) or raise the cap.`);
|
|
339
|
+
}
|
|
340
|
+
// ── 2. Quote ──
|
|
341
|
+
let quote = {
|
|
342
|
+
characters: 0,
|
|
343
|
+
lines: 0,
|
|
344
|
+
dispatches: 0,
|
|
345
|
+
spendable: null,
|
|
346
|
+
sufficient: null,
|
|
347
|
+
lines_skipped: skipped.length,
|
|
348
|
+
lines_resumed: toResume.length,
|
|
349
|
+
};
|
|
350
|
+
let useBatch = false;
|
|
351
|
+
if (toCreate.length > 0) {
|
|
352
|
+
opts.onProgress?.(0, planned.length, `Quoting ${toCreate.length} line(s)`);
|
|
353
|
+
const batchQuote = ctx.features.batch ? await tryBatchQuote(ctx, toCreate, format, numberMode, opts.signal) : null;
|
|
354
|
+
if (batchQuote) {
|
|
355
|
+
quote = { ...quote, ...batchQuote };
|
|
356
|
+
useBatch = true;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
quote = { ...quote, ...(await quoteLines(ctx, toCreate, opts.signal)) };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// The account as it is now: entitlement, key budget, free job slots — and
|
|
363
|
+
// the balance the quote shows and is checked against. Never a balance from
|
|
364
|
+
// a cached quote: the user may have topped up since the first call.
|
|
365
|
+
const work = planned.filter(p => p.action !== 'skip');
|
|
366
|
+
const usage = work.length > 0 ? await readUsage(ctx, opts.signal) : null;
|
|
367
|
+
if (usage && toCreate.length > 0) {
|
|
368
|
+
if (usage.entitled === false) {
|
|
369
|
+
throw AthanLabError.client('plan_required', 'Creating jobs needs an active Max plan (or founder access); this key is read-only. Nothing was created.', {
|
|
370
|
+
upgradeUrl: usage.upgrade_url ?? null,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const remaining = usage.key?.remaining;
|
|
374
|
+
if (typeof remaining === 'number' && remaining < createCharacters) {
|
|
375
|
+
throw AthanLabError.client('key_budget_exceeded', `This key's monthly budget has ${remaining} characters left; the script needs ${createCharacters}. Nothing was created.`, {
|
|
376
|
+
details: { required: createCharacters, remaining, resets_at: usage.key?.resets_at ?? null },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (toCreate.length > 0) {
|
|
381
|
+
// Without usage:read, the freshest dry run's balance (null when every quote came from the cache): the API's 402 stays the authority.
|
|
382
|
+
const spendable = typeof usage?.spendable === 'number' ? usage.spendable : quote.spendable;
|
|
383
|
+
quote = { ...quote, spendable, sufficient: spendable === null ? null : spendable >= quote.characters };
|
|
384
|
+
const needsConfirm = createCharacters > CONFIRM_CHARACTERS_THRESHOLD || toCreate.length > CONFIRM_LINES_THRESHOLD;
|
|
385
|
+
if (needsConfirm && !input.confirm) {
|
|
386
|
+
return {
|
|
387
|
+
...emptyResult(dir, planned, quote, useBatch),
|
|
388
|
+
status: 'confirmation_required',
|
|
389
|
+
message: `Quote only — nothing was created or charged. ${toCreate.length} line(s), ${quote.characters} characters${quote.spendable !== null ? ` (the account can spend ${quote.spendable})` : ''}${skipped.length ? `; ${skipped.length} line(s) already done` : ''}.${recreatedNote} This is above the confirmation threshold (${CONFIRM_CHARACTERS_THRESHOLD} characters or ${CONFIRM_LINES_THRESHOLD} lines): show the quote to the user, then call again with the same arguments and confirm: true.`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (quote.sufficient === false) {
|
|
393
|
+
throw AthanLabError.client('insufficient_characters', `The script needs ${quote.characters} characters but the account can spend ${quote.spendable}. Nothing was created or charged.`, {
|
|
394
|
+
details: { required: quote.characters, spendable: quote.spendable },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// ── 3. Run ──
|
|
399
|
+
let concurrency = 1;
|
|
400
|
+
if (usage) {
|
|
401
|
+
const max = Math.max(1, usage.max_concurrent_jobs);
|
|
402
|
+
concurrency = Math.max(1, Math.min(max - usage.active_jobs, max));
|
|
403
|
+
}
|
|
404
|
+
await ensureDir(dir);
|
|
405
|
+
const now = new Date(ctx.clock.now()).toISOString();
|
|
406
|
+
const manifest = {
|
|
407
|
+
version: 1,
|
|
408
|
+
generator: `athanlab-mcp ${VERSION}`,
|
|
409
|
+
script_id: scriptId,
|
|
410
|
+
output_format: format,
|
|
411
|
+
gaps,
|
|
412
|
+
created_at: previous?.created_at ?? now,
|
|
413
|
+
updated_at: now,
|
|
414
|
+
timeline_complete: false,
|
|
415
|
+
total_duration_seconds: null,
|
|
416
|
+
combined_file: null,
|
|
417
|
+
batch: previous?.batch ?? null,
|
|
418
|
+
lines: planned.map(p => p.entry),
|
|
419
|
+
};
|
|
420
|
+
let saving = Promise.resolve();
|
|
421
|
+
const save = () => {
|
|
422
|
+
manifest.updated_at = new Date(ctx.clock.now()).toISOString();
|
|
423
|
+
const snapshot = JSON.stringify(manifest, null, 2);
|
|
424
|
+
saving = saving.catch(() => { }).then(() => writeFileAtomic(manifestPath, `${snapshot}\n`));
|
|
425
|
+
return saving;
|
|
426
|
+
};
|
|
427
|
+
await save();
|
|
428
|
+
const deadline = ctx.clock.now() + (input.timeout_seconds ?? DEFAULT_SCRIPT_TIMEOUT_SECONDS) * 1000;
|
|
429
|
+
const downloads = new Semaphore(MAX_DOWNLOADS_AT_ONCE);
|
|
430
|
+
let stopError = null;
|
|
431
|
+
let timedOut = false;
|
|
432
|
+
let chargedThisRun = 0;
|
|
433
|
+
let finished = skipped.length;
|
|
434
|
+
const startedAt = ctx.clock.now();
|
|
435
|
+
const report = (message) => opts.onProgress?.(finished, planned.length, message);
|
|
436
|
+
const finishLine = async (p, job) => {
|
|
437
|
+
const target = path.join(dir, p.entry.file);
|
|
438
|
+
await downloads.run(() => downloadJobAudio(ctx, job.id, format, target, opts.signal));
|
|
439
|
+
let duration = job.audio?.duration_seconds ?? null;
|
|
440
|
+
if (format === 'wav') {
|
|
441
|
+
try {
|
|
442
|
+
duration = (await readWavInfo(target)).durationSeconds;
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
ctx.logger.warn('could not read the WAV header; using the API duration', { line: p.entry.index, error: err.message });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const timing = timingOf(job.audio);
|
|
449
|
+
Object.assign(p.entry, {
|
|
450
|
+
status: 'succeeded',
|
|
451
|
+
voice_id: job.input?.voice_id ?? p.entry.requested_voice_id,
|
|
452
|
+
characters_charged: job.usage?.characters_charged ?? p.entry.characters,
|
|
453
|
+
duration_seconds: duration,
|
|
454
|
+
speech_start_seconds: timing.speech_start_seconds,
|
|
455
|
+
speech_end_seconds: timing.speech_end_seconds,
|
|
456
|
+
error: null,
|
|
457
|
+
});
|
|
458
|
+
await save();
|
|
459
|
+
};
|
|
460
|
+
/** Poll an existing job to the end and save its audio. Returns the failed job when it did not succeed. */
|
|
461
|
+
const followJob = async (p, job, firstPoll) => {
|
|
462
|
+
const waited = await waitForJob(ctx, job, firstPoll, {
|
|
463
|
+
deadline,
|
|
464
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
465
|
+
// Every poll is reported (the lines-done count unchanged: a heartbeat), so a long or cold-starting line is never silent.
|
|
466
|
+
onProgress: (polled) => report(`Line ${p.entry.index + 1}: ${polled.status}, ${Math.round((ctx.clock.now() - startedAt) / 1000)} s`),
|
|
467
|
+
});
|
|
468
|
+
if (waited.timedOut)
|
|
469
|
+
return 'timed_out';
|
|
470
|
+
if (waited.job.status === 'succeeded') {
|
|
471
|
+
await finishLine(p, waited.job);
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
return waited.job;
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* A followed job that can no longer deliver: the line is marked failed and
|
|
478
|
+
* left for the next run, which plans it as a new line — quoted, under the
|
|
479
|
+
* per-call cap and the confirmation, and charged only then.
|
|
480
|
+
*/
|
|
481
|
+
const giveUp = async (entry, code, message) => {
|
|
482
|
+
if (code === 'not_found')
|
|
483
|
+
entry.job_id = null;
|
|
484
|
+
Object.assign(entry, {
|
|
485
|
+
status: 'failed',
|
|
486
|
+
idempotency_key: null,
|
|
487
|
+
error: { code, message: `${message} Run the same call again to generate this line anew (it is quoted first and charged again).` },
|
|
488
|
+
});
|
|
489
|
+
finished++;
|
|
490
|
+
await save();
|
|
491
|
+
report(`Line ${entry.index + 1} failed`);
|
|
492
|
+
};
|
|
493
|
+
/** The job an idempotency_conflict names, when it carries this line's request; null otherwise. */
|
|
494
|
+
const conflictingJob = async (p, e) => {
|
|
495
|
+
const jobId = e.details?.job_id;
|
|
496
|
+
if (typeof jobId !== 'string' || !JOB_ID_PATTERN.test(jobId))
|
|
497
|
+
return null;
|
|
498
|
+
try {
|
|
499
|
+
const job = await getJob(ctx, jobId, { deadline, ...(opts.signal ? { signal: opts.signal } : {}) });
|
|
500
|
+
return jobMatchesLine(job, p.entry, format) ? job : null;
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
if (toAthanLabError(err).code === 'not_found')
|
|
504
|
+
return null;
|
|
505
|
+
throw err;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* One line to the end. Only a line planned as 'create' (and not handed over
|
|
510
|
+
* by a batch) may create a job — its characters were quoted, capped and
|
|
511
|
+
* confirmed. A resumed or batch line is followed, and never re-created here.
|
|
512
|
+
*/
|
|
513
|
+
const processLine = async (p, followOnly = false) => {
|
|
514
|
+
const entry = p.entry;
|
|
515
|
+
const mayCreate = p.action === 'create' && !followOnly;
|
|
516
|
+
const waitOpts = { deadline, ...(opts.signal ? { signal: opts.signal } : {}) };
|
|
517
|
+
let conflictHandled = false;
|
|
518
|
+
try {
|
|
519
|
+
let following = null;
|
|
520
|
+
if (entry.job_id) {
|
|
521
|
+
try {
|
|
522
|
+
following = p.probed ?? await getJob(ctx, entry.job_id, waitOpts);
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
const e = toAthanLabError(err);
|
|
526
|
+
if (e.code !== 'not_found')
|
|
527
|
+
throw e;
|
|
528
|
+
return await giveUp(entry, e.code, `Job ${entry.job_id} no longer exists.`);
|
|
529
|
+
}
|
|
530
|
+
p.probed = undefined;
|
|
531
|
+
}
|
|
532
|
+
for (;;) {
|
|
533
|
+
let outcome;
|
|
534
|
+
let chargedNow = 0;
|
|
535
|
+
if (following) {
|
|
536
|
+
// The job exists (and was paid for): follow it.
|
|
537
|
+
try {
|
|
538
|
+
outcome = await followJob(p, following, 0);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
const e = toAthanLabError(err);
|
|
542
|
+
if (e.code !== 'audio_expired' && e.code !== 'job_failed' && e.code !== 'not_found')
|
|
543
|
+
throw e;
|
|
544
|
+
return await giveUp(entry, e.code, e.message);
|
|
545
|
+
}
|
|
546
|
+
following = null;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
if (!mayCreate)
|
|
550
|
+
return;
|
|
551
|
+
if (stopError || ctx.clock.now() >= deadline) {
|
|
552
|
+
if (!stopError)
|
|
553
|
+
timedOut = true;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (!entry.idempotency_key) {
|
|
557
|
+
entry.idempotency_key = ctx.newIdempotencyKey();
|
|
558
|
+
entry.request_metadata = p.input.metadata ?? null;
|
|
559
|
+
}
|
|
560
|
+
entry.status = 'pending';
|
|
561
|
+
entry.attempts += 1;
|
|
562
|
+
await save();
|
|
563
|
+
let created;
|
|
564
|
+
try {
|
|
565
|
+
created = await createSpeechJob(ctx, p.input, entry.idempotency_key, waitOpts);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
const e = toAthanLabError(err);
|
|
569
|
+
if (e.code !== 'idempotency_conflict' || conflictHandled)
|
|
570
|
+
throw e;
|
|
571
|
+
// The saved key was first used with a different body. It is this
|
|
572
|
+
// line's own key, so its job is this line's earlier attempt: adopt
|
|
573
|
+
// it when it carries this line's request (paid then, not now);
|
|
574
|
+
// otherwise start over with a new key.
|
|
575
|
+
conflictHandled = true;
|
|
576
|
+
entry.attempts -= 1;
|
|
577
|
+
const earlier = await conflictingJob(p, e);
|
|
578
|
+
if (earlier) {
|
|
579
|
+
ctx.logger.info('adopting the job an earlier run created under this line\'s key', { line: entry.index, job_id: earlier.id });
|
|
580
|
+
entry.job_id = earlier.id;
|
|
581
|
+
entry.status = 'processing';
|
|
582
|
+
await save();
|
|
583
|
+
following = earlier;
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
ctx.logger.warn('saved Idempotency-Key belongs to a different request; using a new key', { line: entry.index });
|
|
587
|
+
resetForNewJob(p);
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
entry.job_id = created.job.id;
|
|
592
|
+
entry.status = 'processing';
|
|
593
|
+
if (!created.replayed)
|
|
594
|
+
chargedNow = created.job.usage?.characters_charged ?? entry.characters;
|
|
595
|
+
chargedThisRun += chargedNow;
|
|
596
|
+
await save();
|
|
597
|
+
outcome = await followJob(p, created.job, created.firstPollSeconds);
|
|
598
|
+
}
|
|
599
|
+
if (outcome === null) {
|
|
600
|
+
finished++;
|
|
601
|
+
report(`Line ${entry.index + 1} done`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (outcome === 'timed_out') {
|
|
605
|
+
timedOut = true;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const failure = jobOutcomeError(outcome);
|
|
609
|
+
// Refunds count against this run's charge only for a job this run paid for.
|
|
610
|
+
if (outcome.status === 'failed' && chargedNow > 0)
|
|
611
|
+
chargedThisRun -= outcome.usage?.characters_refunded ?? 0;
|
|
612
|
+
if (mayCreate && outcome.status === 'failed' && failure.retryable && entry.attempts < MAX_JOB_ATTEMPTS && !stopError) {
|
|
613
|
+
// Refunded; a new key, since the old one would replay this failure.
|
|
614
|
+
resetForNewJob(p);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
entry.status = outcome.status === 'cancelled' ? 'cancelled' : 'failed';
|
|
618
|
+
entry.error = { code: failure.code, message: failure.message };
|
|
619
|
+
finished++;
|
|
620
|
+
await save();
|
|
621
|
+
report(`Line ${entry.index + 1} ${entry.status}`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
const e = toAthanLabError(err);
|
|
627
|
+
if (opts.signal?.aborted)
|
|
628
|
+
throw e;
|
|
629
|
+
if (LINE_ERROR_CODES.has(e.code)) {
|
|
630
|
+
entry.status = 'failed';
|
|
631
|
+
entry.error = { code: e.code, message: e.message };
|
|
632
|
+
entry.idempotency_key = null;
|
|
633
|
+
finished++;
|
|
634
|
+
await save();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (e.code === 'timeout' && ctx.clock.now() >= deadline) {
|
|
638
|
+
timedOut = true;
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Everything else stops new work: the account, the key or the service
|
|
642
|
+
// is the problem, not this line. The entry keeps its key/job for a resume
|
|
643
|
+
// (re-running the script reuses it — no idempotency_key to hand back).
|
|
644
|
+
if (e.details && 'idempotency_key' in e.details) {
|
|
645
|
+
const { idempotency_key: _saved, ...rest } = e.details;
|
|
646
|
+
e.details = Object.keys(rest).length > 0 ? rest : null;
|
|
647
|
+
}
|
|
648
|
+
if (!stopError)
|
|
649
|
+
stopError = e;
|
|
650
|
+
if (!ACCOUNT_STOP_CODES.has(e.code))
|
|
651
|
+
ctx.logger.warn('script stopped on an unexpected error', { code: e.code, line: entry.index });
|
|
652
|
+
entry.error = { code: e.code, message: e.message };
|
|
653
|
+
await save();
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
report(`Generating ${work.length} line(s), ${concurrency} at a time`);
|
|
657
|
+
let usedBatch = false;
|
|
658
|
+
// A line still holding a per-line Idempotency-Key (an earlier run's POST that
|
|
659
|
+
// may have landed) must replay with that key; in a batch it could be bought twice.
|
|
660
|
+
const batchable = toCreate.filter(p => !p.entry.idempotency_key);
|
|
661
|
+
if (useBatch && batchable.length > 0) {
|
|
662
|
+
const { runBatch } = await import('./batch.js');
|
|
663
|
+
// Resumed lines and keyed lines run alongside the batch.
|
|
664
|
+
const alongside = pool([...toResume, ...toCreate.filter(p => p.entry.idempotency_key)], concurrency, () => false, p => processLine(p));
|
|
665
|
+
usedBatch = await runBatch(ctx, {
|
|
666
|
+
lines: batchable,
|
|
667
|
+
format,
|
|
668
|
+
numberMode,
|
|
669
|
+
manifest,
|
|
670
|
+
save,
|
|
671
|
+
deadline,
|
|
672
|
+
signal: opts.signal,
|
|
673
|
+
followLine: async (line, charged) => {
|
|
674
|
+
if (charged)
|
|
675
|
+
chargedThisRun += line.entry.characters;
|
|
676
|
+
const planned = batchable.find(p => p.entry === line.entry);
|
|
677
|
+
if (planned)
|
|
678
|
+
await processLine(planned, true);
|
|
679
|
+
},
|
|
680
|
+
onStop: (e) => { stopError ??= e; },
|
|
681
|
+
onTimeout: () => { timedOut = true; },
|
|
682
|
+
});
|
|
683
|
+
await alongside;
|
|
684
|
+
// No batches after all: one job per line, for the lines no earlier batch already gave a job.
|
|
685
|
+
if (!usedBatch)
|
|
686
|
+
await pool(batchable.filter(p => !p.entry.job_id), concurrency, () => false, p => processLine(p));
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
await pool(work, concurrency, () => false, p => processLine(p));
|
|
690
|
+
}
|
|
691
|
+
await saving.catch(() => { });
|
|
692
|
+
// ── 4. Finish ──
|
|
693
|
+
const timeline = layoutTimeline(planned.map(p => ({ speaker: p.entry.speaker, durationSeconds: p.entry.status === 'succeeded' ? p.entry.duration_seconds : null })), gaps);
|
|
694
|
+
planned.forEach((p, i) => {
|
|
695
|
+
const slot = timeline.slots[i];
|
|
696
|
+
p.entry.start_seconds = roundMs(slot?.start ?? null);
|
|
697
|
+
p.entry.end_seconds = roundMs(slot?.end ?? null);
|
|
698
|
+
});
|
|
699
|
+
manifest.timeline_complete = timeline.complete;
|
|
700
|
+
manifest.total_duration_seconds = roundMs(timeline.totalSeconds);
|
|
701
|
+
let combinedPath = null;
|
|
702
|
+
let combineError = null;
|
|
703
|
+
if (input.combine) {
|
|
704
|
+
if (timeline.complete) {
|
|
705
|
+
try {
|
|
706
|
+
const target = path.join(dir, COMBINED_FILE);
|
|
707
|
+
await combineWavFiles(planned.map((p, i) => ({
|
|
708
|
+
file: path.join(dir, p.entry.file),
|
|
709
|
+
startSeconds: timeline.slots[i]?.start ?? 0,
|
|
710
|
+
})), target);
|
|
711
|
+
combinedPath = target;
|
|
712
|
+
manifest.combined_file = COMBINED_FILE;
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
combineError = toAthanLabError(err).toJSON();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
combineError = { code: 'incomplete', message: 'combined.wav is written once every line has succeeded. Run the same call again to finish the remaining lines.' };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
await save();
|
|
723
|
+
const counts = {
|
|
724
|
+
succeeded: planned.filter(p => p.entry.status === 'succeeded').length,
|
|
725
|
+
failed: planned.filter(p => p.entry.status === 'failed' || p.entry.status === 'cancelled').length,
|
|
726
|
+
};
|
|
727
|
+
const pending = planned.length - counts.succeeded - counts.failed;
|
|
728
|
+
const status = stopError
|
|
729
|
+
? 'stopped'
|
|
730
|
+
: timedOut
|
|
731
|
+
? 'timed_out'
|
|
732
|
+
: counts.succeeded === planned.length ? 'completed' : 'partial';
|
|
733
|
+
const stopped = stopError ? stopError.toJSON() : null;
|
|
734
|
+
const message = [
|
|
735
|
+
`${counts.succeeded}/${planned.length} line(s) ready in ${dir}${skipped.length ? ` (${skipped.length} already done, not charged again)` : ''}.`,
|
|
736
|
+
recreated.length ? `${recreated.length} line(s) were generated again: their earlier job could no longer deliver audio (deleted after 30 days, gone, or failed).` : '',
|
|
737
|
+
counts.failed ? `${counts.failed} failed — see each line's error.` : '',
|
|
738
|
+
stopError ? `Stopped creating new jobs: ${stopError.code}. ${stopError.action}` : '',
|
|
739
|
+
timedOut ? 'The time limit passed; running jobs continue on AthanLab\'s side. Call again with the same arguments to resume — finished lines are skipped and running jobs are followed, not paid again.' : '',
|
|
740
|
+
status === 'partial' || status === 'stopped' ? 'Re-run the same call to retry what is left; finished lines are skipped.' : '',
|
|
741
|
+
combinedPath ? `combined.wav: ${roundMs(timeline.totalSeconds)} s.` : '',
|
|
742
|
+
combineError && input.combine ? `combined.wav not written: ${String(combineError.message)}` : '',
|
|
743
|
+
].filter(Boolean).join(' ');
|
|
744
|
+
return {
|
|
745
|
+
status,
|
|
746
|
+
directory: dir,
|
|
747
|
+
manifest_path: manifestPath,
|
|
748
|
+
combined_path: combinedPath,
|
|
749
|
+
quote: toCreate.length > 0 ? quote : null,
|
|
750
|
+
lines_total: planned.length,
|
|
751
|
+
lines_succeeded: counts.succeeded,
|
|
752
|
+
lines_skipped: skipped.length,
|
|
753
|
+
lines_failed: counts.failed,
|
|
754
|
+
lines_pending: pending,
|
|
755
|
+
characters_charged: Math.max(0, chargedThisRun),
|
|
756
|
+
total_duration_seconds: roundMs(timeline.totalSeconds),
|
|
757
|
+
timeline_complete: timeline.complete,
|
|
758
|
+
stopped,
|
|
759
|
+
combine_error: combineError,
|
|
760
|
+
used_batch: usedBatch,
|
|
761
|
+
message,
|
|
762
|
+
lines: planned.map(p => lineResult(dir, p.entry)),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function lineResult(dir, entry) {
|
|
766
|
+
return {
|
|
767
|
+
index: entry.index,
|
|
768
|
+
id: entry.id,
|
|
769
|
+
speaker: entry.speaker,
|
|
770
|
+
status: entry.status,
|
|
771
|
+
file: entry.status === 'succeeded' && entry.file ? path.join(dir, entry.file) : null,
|
|
772
|
+
job_id: entry.job_id,
|
|
773
|
+
duration_seconds: roundMs(entry.duration_seconds),
|
|
774
|
+
start_seconds: entry.start_seconds,
|
|
775
|
+
end_seconds: entry.end_seconds,
|
|
776
|
+
error: entry.error,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function emptyResult(dir, planned, quote, usedBatch) {
|
|
780
|
+
return {
|
|
781
|
+
directory: dir,
|
|
782
|
+
manifest_path: null,
|
|
783
|
+
combined_path: null,
|
|
784
|
+
quote,
|
|
785
|
+
lines_total: planned.length,
|
|
786
|
+
lines_succeeded: planned.filter(p => p.action === 'skip').length,
|
|
787
|
+
lines_skipped: planned.filter(p => p.action === 'skip').length,
|
|
788
|
+
lines_failed: 0,
|
|
789
|
+
lines_pending: planned.filter(p => p.action !== 'skip').length,
|
|
790
|
+
characters_charged: 0,
|
|
791
|
+
total_duration_seconds: null,
|
|
792
|
+
timeline_complete: false,
|
|
793
|
+
stopped: null,
|
|
794
|
+
combine_error: null,
|
|
795
|
+
used_batch: usedBatch,
|
|
796
|
+
lines: planned.map(p => lineResult(dir, p.entry)),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
async function readUsage(ctx, signal) {
|
|
800
|
+
try {
|
|
801
|
+
return (await ctx.client.json('/usage', signal ? { signal } : {})).data;
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
const e = toAthanLabError(err);
|
|
805
|
+
// A key without usage:read can still run a script, one line at a time.
|
|
806
|
+
if (e.code === 'scope_missing')
|
|
807
|
+
return null;
|
|
808
|
+
throw e;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* A dry run per line. What a line costs is cached for 30 minutes, so the
|
|
813
|
+
* confirm: true call does not quote again; the balance is not (spendable here
|
|
814
|
+
* comes only from dry runs made now, and the caller prefers GET /usage).
|
|
815
|
+
*/
|
|
816
|
+
async function quoteLines(ctx, lines, signal) {
|
|
817
|
+
const cache = ctx.quoteCache;
|
|
818
|
+
const problems = [];
|
|
819
|
+
let characters = 0;
|
|
820
|
+
let dispatches = 0;
|
|
821
|
+
let spendable = null;
|
|
822
|
+
await pool(lines, QUOTE_CONCURRENCY, () => false, async (p) => {
|
|
823
|
+
const key = sha256(JSON.stringify({ ...p.input, metadata: undefined }));
|
|
824
|
+
const cached = cache?.get(key);
|
|
825
|
+
let cost;
|
|
826
|
+
if (cached && ctx.clock.now() - cached.at < QUOTE_CACHE_TTL_MS) {
|
|
827
|
+
cost = cached;
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
let quote;
|
|
831
|
+
try {
|
|
832
|
+
quote = await quoteSpeech(ctx, { ...p.input, metadata: undefined }, signal);
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
const e = toAthanLabError(err);
|
|
836
|
+
if (LINE_ERROR_CODES.has(e.code)) {
|
|
837
|
+
problems.push({ index: p.entry.index, error: e });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
throw e;
|
|
841
|
+
}
|
|
842
|
+
cost = { characters: quote.characters, dispatches: quote.dispatches };
|
|
843
|
+
cache?.set(key, { ...cost, at: ctx.clock.now() });
|
|
844
|
+
if (typeof quote.spendable === 'number')
|
|
845
|
+
spendable = spendable === null ? quote.spendable : Math.min(spendable, quote.spendable);
|
|
846
|
+
}
|
|
847
|
+
characters += cost.characters;
|
|
848
|
+
dispatches += cost.dispatches;
|
|
849
|
+
});
|
|
850
|
+
if (problems.length > 0) {
|
|
851
|
+
problems.sort((a, b) => a.index - b.index);
|
|
852
|
+
throw AthanLabError.client('invalid_lines', `${problems.length} line(s) would be refused, so nothing was created or charged: ${problems.map(p => `line ${p.index}: ${p.error.code} — ${p.error.message}`).join('; ')}`, {
|
|
853
|
+
details: { line_errors: problems.map(p => ({ index: p.index, code: p.error.code, message: p.error.message, param: p.error.param })) },
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
const total = spendable;
|
|
857
|
+
return {
|
|
858
|
+
characters,
|
|
859
|
+
lines: lines.length,
|
|
860
|
+
dispatches,
|
|
861
|
+
spendable: total,
|
|
862
|
+
sufficient: total === null ? null : total >= characters,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
async function tryBatchQuote(ctx, lines, format, numberMode, signal) {
|
|
866
|
+
const { quoteBatch } = await import('./batch.js');
|
|
867
|
+
return quoteBatch(ctx, lines.map(p => ({ entry: p.entry, input: p.input, carried: p.carried })), format, numberMode, signal);
|
|
868
|
+
}
|
|
869
|
+
//# sourceMappingURL=script.js.map
|