lomasub 0.1.2 → 0.2.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.
Files changed (5) hide show
  1. package/README.md +28 -5
  2. package/cli.js +72 -17
  3. package/index.d.ts +16 -4
  4. package/index.js +65 -10
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -20,13 +20,19 @@ For a Node.js project, run `npm install lomasub`. For a persistent CLI, run `npm
20
20
  ```bash
21
21
  lomasub transcribe video.mp4 --output video.srt
22
22
  lomasub transcribe video.mp4 --speaker-diarization --srt-max-chars 48 --srt-cue-words 6
23
- lomasub transcribe video.mp4 --config options.json --no-revise-srt
23
+ lomasub transcribe video.mp4 --config options.json
24
24
  lomasub transcribe video.mp4 --no-ffmpeg
25
25
  lomasub download <job-id> --output video.srt
26
+ lomasub transcribe video.mp4 --format txt # prints only the text, one line
27
+ lomasub transcribe video.mp4 --format txt -v # same, plus progress on stderr
28
+ lomasub transcribe video.mp4 --format txt -o video.txt
29
+ lomasub download <job-id> --format txt --speakers
26
30
  lomasub --help
27
31
  ```
28
32
 
29
- The command uploads the file, creates the job, waits, and saves `<input-stem>.srt` beside the input. `--output` changes the destination; `--overwrite` permits replacing an existing file. Progress and the job ID go to stderr; the saved path goes to stdout. If interrupted, the server job continues: resume using `download` and the printed job ID. Errors exit with code 1; cancellation exits with code 130. Job creation is never automatically retried.
33
+ The command uploads the file, creates the job, waits, and saves `<input-stem>.srt` beside the input. `--output` changes the destination; `--overwrite` permits replacing an existing file. Progress and the job ID go to stderr; the saved path goes to stdout. `--output -` prints the result itself to stdout instead of saving it.
34
+
35
+ `--format txt` returns the transcript as plain text: every cue joined by a single space on one line, with no line breaks at all (not even at the end), no numbers, timings or markup. It is made for scripts: by default it prints **only** the text to stdout, with no progress lines and no file. `--output <file>` saves it instead (silently), `--verbose` / `-v` shows the progress, timing and saved path as SRT does, and `download <job-id> --format txt` needs no `--output`. Errors are always reported on stderr with exit code 1, naming the job ID so it can be fetched later with `download`. It follows the editor: once a subtitle is edited and saved, the text reflects the edit. Speaker labels (`A: ...`, from `--speaker-diarization`) are dropped unless `--speakers` is given. The default format is `srt`. If interrupted, the server job continues: resume using `download` and the printed job ID. Errors exit with code 1; cancellation exits with code 130. Job creation is never automatically retried.
30
36
 
31
37
  When ffmpeg is on `PATH`, audio is extracted into a temporary Matroska audio file using `-map 0:a:0 -vn -c:a copy`. This copies the first audio stream without encoding, resampling, or changing quality. It excludes video to reduce uploads. If ffmpeg is missing or extraction fails (including a codec unsupported by Matroska), the original file is uploaded. `--no-ffmpeg` disables extraction. Temporary files are removed afterward.
32
38
 
@@ -42,7 +48,7 @@ When ffmpeg is on `PATH`, audio is extracted into a temporary Matroska audio fil
42
48
  }
43
49
  ```
44
50
 
45
- Every current transcription config field has a kebab-case flag. Boolean fields support `--name` and `--no-name`. Explicit flags override JSON values, including `false` and `0`. Unset settings use server defaults; the API validates settings and reports retired names. Additional config keys pass through unchanged.
51
+ Supported transcription flags use kebab-case. Use `--promodel pro` (SDK: `config: { promodel: "pro" }`) for word correction before alignment. Revision settings (`revise_srt`, `revise_speakername`, `srt_revision_backend`) are not supported by this CLI/SDK, including through `--config` JSON. Boolean fields support `--name` and `--no-name`. Explicit flags override JSON values, including `false` and `0`. Unset settings use server defaults; the API validates settings and reports retired names. Other additional config keys pass through unchanged.
46
52
 
47
53
  | Settings | CLI flags |
48
54
  | --- | --- |
@@ -50,10 +56,11 @@ Every current transcription config field has a kebab-case flag. Boolean fields s
50
56
  | VAD | `--min-vad-duration-seconds`, `--max-vad-duration-seconds`, `--vad-threshold` |
51
57
  | Subtitle cues | `--min-srt-duration-ms`, `--srt-max-chars`, `--srt-cue-words` |
52
58
  | Diarization | `--speaker-diarization`, `--speaker-local-distance-threshold`, `--speaker-merge-distance-threshold`, `--speaker-clustering-drop-dialogue-percentile`, `--agglomeration-min-appear`, `--agglomolation-group-size` |
53
- | Revision | `--revise-srt`, `--revise-speakername`, `--srt-revision-backend` (`agy`, `codex`) |
54
59
 
55
60
  `--filename`, `--language`, and `--duration-seconds` are job metadata, outside config. The original local filename is preserved unless overridden. An explicit duration is forwarded to upload preparation and job creation; duration is not automatically measured.
56
61
 
62
+ The server defaults omitted, null, or blank language to `th`; `--language en` overrides it. The Thai hint permits Thai, English, and mixed transcripts: readable English is not discarded because of its script. This is a model hint, not automatic language detection.
63
+
57
64
  Use `--endpoint http://localhost:8080` for another API server. The default is `https://api.lomasub.ai`. Bearer credentials are sent only to the API, never to storage upload/download URLs.
58
65
 
59
66
  ## SDK
@@ -80,10 +87,14 @@ Results include API job data and whichever artifact URLs the server produced. `t
80
87
  | `waitForJob(jobId, options?)` | Poll until READY; throw with the failed stage on FAILED |
81
88
  | `transcribeFile(file, options?)` | Submit, wait, optionally save SRT, and return timing |
82
89
  | `downloadSrt(jobId, output, options?)` | Wait if needed, download original SRT, return absolute saved path |
90
+ | `downloadText(jobId, output, options?)` | Wait if needed, save the plain-text transcript, return absolute saved path |
91
+ | `getText(jobId, { signal, speakers }?)` | The plain-text transcript of a READY job as a string |
92
+ | `readResult(jobId, options?)` | Wait if needed and return the result as a string (`format`: `srt` or `txt`) |
93
+ | `resultText(status, options?)` | The result of a READY status (such as `transcribeFile`'s) as a string, without polling again |
83
94
 
84
95
  Transcription options: `config`, `filename`, `language`, `duration_seconds`, `ffmpeg` (default `true`), `signal`, `onProgress`, and `pollIntervalMs` (default `2000`). Waiting/downloading accept `signal`, `onProgress`, and `pollIntervalMs`; downloading also accepts `overwrite` (default `false`). Output parent directories must already exist.
85
96
 
86
- `transcribeFile()` additionally accepts `output` (SRT path) and `overwrite` (default `false`). Existing output files are rejected before uploading unless overwrite is enabled.
97
+ `transcribeFile()` additionally accepts `output` (result path), `format` (`srt` default, or `txt`), `speakers` (txt only, default `false`) and `overwrite` (default `false`). Existing output files are rejected before uploading unless overwrite is enabled.
87
98
 
88
99
  After successful CLI transcription, stderr includes a final summary:
89
100
 
@@ -109,3 +120,15 @@ npm pack --dry-run
109
120
  ```
110
121
 
111
122
  Tests use local mock HTTP servers. One optional ffmpeg test checks that stream-copy extraction preserves audio packet bytes. No production jobs are submitted.
123
+
124
+
125
+ ### Translation
126
+
127
+ Use one output mode, for example `--promodel translate-zh` for **Simplified Chinese**.
128
+ Other targets: `translate-th` (Thai), `translate-en` (English), `translate-ja`
129
+ (Japanese), `translate-ko` (Korean). `translate-jp` and `translate-kr` are aliases.
130
+ SDK: `config: { promodel: "translate-zh" }`. `language` still names the source
131
+ language. Translation combines conservative correction and translation in one
132
+ batched stage; it never runs `pro` first. Omit the option for original subtitles.
133
+ Use alignment `default` or `none`; `llmsplitting` is incompatible with translation.
134
+ Translated results have fixed cue timing, plain SRT/VTT, and no word-level karaoke.
package/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { lstat, readFile } from 'node:fs/promises';
3
3
  import { extname, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { LomaClient, TRANSCRIPTION_OPTIONS } from './index.js';
5
+ import { LomaClient, RESULT_FORMATS, TRANSCRIPTION_OPTIONS } from './index.js';
6
6
 
7
7
  const options = {
8
8
  help: { type: 'boolean', short: 'h' },
@@ -10,6 +10,7 @@ const options = {
10
10
  config: { type: 'string' }, language: { type: 'string' }, filename: { type: 'string' },
11
11
  'duration-seconds': { type: 'string' },
12
12
  'no-ffmpeg': { type: 'boolean' }, overwrite: { type: 'boolean' },
13
+ format: { type: 'string' }, speakers: { type: 'boolean' }, verbose: { type: 'boolean', short: 'v' },
13
14
  };
14
15
  for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
15
16
  const flag = key.replaceAll('_', '-');
@@ -17,6 +18,24 @@ for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
17
18
  if (type === 'boolean') options[`no-${flag}`] = { type: 'boolean' };
18
19
  }
19
20
 
21
+ // Display-only production defaults; omitted options still use the API's settings.
22
+ const DEFAULT_HELP = {
23
+ forcealigner_level: 'default; choices: default, llmsplitting, none',
24
+ promodel: 'disabled; pro or translate-th/en/ja/ko/zh (zh = Simplified Chinese; jp/kr aliases)',
25
+ min_vad_duration_seconds: '10',
26
+ max_vad_duration_seconds: '25',
27
+ vad_threshold: '0.30',
28
+ min_srt_duration_ms: '1200',
29
+ srt_max_chars: '48',
30
+ srt_cue_words: '6',
31
+ speaker_diarization: 'disabled',
32
+ speaker_local_distance_threshold: '0.60',
33
+ speaker_merge_distance_threshold: '0.35',
34
+ speaker_clustering_drop_dialogue_percentile: '0.02',
35
+ agglomeration_min_appear: '5',
36
+ agglomolation_group_size: '300',
37
+ };
38
+
20
39
  function number(value, flag) {
21
40
  const parsed = Number(value);
22
41
  if (!value.trim() || !Number.isFinite(parsed)) throw new Error(`--${flag} requires a number.`);
@@ -29,29 +48,43 @@ async function main() {
29
48
  console.log(`Usage:
30
49
  lomasub transcribe <file> [options]
31
50
  lomasub download <job-id> --output <file.srt> [--overwrite]
51
+ lomasub download <job-id> --format txt [--output <file.txt>]
32
52
 
33
53
  Set LOMA_API_SECRET_KEY before running. Default endpoint: https://api.lomasub.ai
34
54
  Options:
35
- --endpoint <url> API endpoint
36
- --output, -o <path> Output SRT (default: beside input)
55
+ --output, -o <path> Output file (srt default: beside input); - prints to stdout
56
+ --format <srt|txt> srt (default) or txt: all cues on one line, joined by spaces.
57
+ txt prints only the text to stdout unless --output is given
58
+ --speakers txt only: keep speaker labels (A: ...)
59
+ --verbose, -v txt only: show progress and timing on stderr (srt always does)
37
60
  --overwrite Replace existing output
38
61
  --no-ffmpeg Upload original without audio extraction
39
- --language <code> Audio language
62
+ --language <code> ASR language hint: th, en; server default: th
40
63
  --filename <name> Job display filename
41
- --duration-seconds N Optional media duration hint
42
64
  --config <path> JSON transcription config; explicit flags override it
43
65
 
44
- Transcription options (unset values use server defaults):
45
- ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceAll('_', '-')}${type === 'boolean' ? ' / --no-' + key.replaceAll('_', '-') : ' <' + type + '>'}`).join('\n')}`);
66
+ Transcription options:
67
+ Defaults shown are for api.lomasub.ai; omitted options use server settings.
68
+ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => {
69
+ const flag = key.replaceAll('_', '-');
70
+ const label = `--${flag}${type === 'boolean' ? ` / --no-${flag}` : ` <${type}>`}`;
71
+ return ` ${label.padEnd(56)}default: ${DEFAULT_HELP[key]}`;
72
+ }).join('\n')}`);
46
73
  return;
47
74
  }
48
75
  const [command, input] = positionals;
49
76
  if (!['transcribe', 'download'].includes(command) || !input || positionals.length !== 2) {
50
77
  throw new Error('Use transcribe <file> or download <job-id>. See --help.');
51
78
  }
52
- if (command === 'download' && !values.output) throw new Error('download requires --output.');
53
- const output = resolve(values.output ?? `${input.slice(0, input.length - extname(input).length)}.srt`);
54
- if (!values.overwrite) {
79
+ const format = values.format ?? 'srt';
80
+ if (!RESULT_FORMATS.includes(format)) throw new Error(`--format must be one of: ${RESULT_FORMATS.join(', ')}.`);
81
+ if (values.speakers && format !== 'txt') throw new Error('--speakers requires --format txt.');
82
+ if (command === 'download' && !values.output && format !== 'txt') throw new Error('download requires --output.');
83
+ // txt is made for pipes: the text alone on stdout, nothing else unless --verbose.
84
+ const stdout = values.output === '-' || (format === 'txt' && values.output === undefined);
85
+ const verbose = format === 'srt' || values.verbose;
86
+ const output = stdout ? undefined : resolve(values.output ?? `${input.slice(0, input.length - extname(input).length)}.${format}`);
87
+ if (!stdout && !values.overwrite) {
55
88
  try {
56
89
  await lstat(output);
57
90
  throw new Error(`Output exists: ${output}. Use --overwrite to replace it.`);
@@ -65,26 +98,46 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceA
65
98
  if (values[`no-${flag}`]) config[key] = false;
66
99
  else if (values[flag] !== undefined) config[key] = type === 'number' ? number(values[flag], flag) : values[flag];
67
100
  }
101
+ let jobId = command === 'download' ? input : undefined;
68
102
  const controller = new AbortController();
69
103
  const cancel = () => controller.abort();
70
104
  const settings = {
71
105
  signal: controller.signal, overwrite: values.overwrite, ffmpeg: !values['no-ffmpeg'],
106
+ format, speakers: values.speakers,
72
107
  language: values.language, filename: values.filename, config,
73
108
  duration_seconds: values['duration-seconds'] === undefined ? undefined : number(values['duration-seconds'], 'duration-seconds'),
74
- onProgress: ({ stage, job_id, state, message }) => console.error([stage, job_id, state, message].filter(Boolean).join(': ')),
109
+ onProgress: ({ stage, job_id, state, message }) => {
110
+ if (job_id) jobId = job_id;
111
+ if (verbose) console.error([stage, job_id, state, message].filter(Boolean).join(': '));
112
+ },
75
113
  };
76
114
  process.once('SIGINT', cancel);
77
115
  process.once('SIGTERM', cancel);
78
116
  try {
79
117
  const client = new LomaClient({ endpoint: values.endpoint });
80
- if (command === 'download') console.log(await client.downloadSrt(input, output, settings));
81
- else {
118
+ // stdout carries exactly the result (txt has no trailing newline); progress stays on stderr.
119
+ const print = text => new Promise((done, fail) => process.stdout.write(text, error => error ? fail(error) : done()));
120
+ if (command === 'download') {
121
+ if (stdout) await print(await client.readResult(input, settings));
122
+ else {
123
+ const saved = format === 'txt' ? await client.downloadText(input, output, settings) : await client.downloadSrt(input, output, settings);
124
+ if (verbose) console.log(saved);
125
+ }
126
+ } else {
82
127
  const result = await client.transcribeFile(input, { ...settings, output });
128
+ const text = stdout ? await client.resultText(result, settings) : undefined;
83
129
  const { content_duration_seconds, total_seconds, speed_x } = result.timing;
84
- const format = (value, unit) => value === null ? 'unknown' : `${value.toFixed(2)}${unit}`;
85
- console.error(`Content: ${format(content_duration_seconds, 's')} | Total: ${format(total_seconds, 's')} | Speed: ${format(speed_x, 'x realtime')}`);
86
- console.log(result.output_path);
130
+ const show = (value, unit) => value === null ? 'unknown' : `${value.toFixed(2)}${unit}`;
131
+ // Before the text: a txt result ends without a newline, so nothing may follow it.
132
+ if (verbose) console.error(`Content: ${show(content_duration_seconds, 's')} | Total: ${show(total_seconds, 's')} | Speed: ${show(speed_x, 'x realtime')}`);
133
+ if (stdout) await print(text);
134
+ else if (verbose) console.log(result.output_path);
87
135
  }
136
+ } catch (error) {
137
+ // Quiet mode never printed the job id, so a cancel or failure names it for `download`.
138
+ error.jobId = jobId;
139
+ if (jobId && !verbose && error.name !== 'AbortError' && !error.message.includes(jobId)) error.message = `${error.message} (job ${jobId})`;
140
+ throw error;
88
141
  } finally {
89
142
  process.removeListener('SIGINT', cancel);
90
143
  process.removeListener('SIGTERM', cancel);
@@ -92,6 +145,8 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => ` --${key.replaceA
92
145
  }
93
146
 
94
147
  main().catch(error => {
95
- console.error(error.name === 'AbortError' ? 'Cancelled. An already-submitted job continues; resume with lomasub download <job-id> --output <path>.' : error.message);
148
+ console.error(error.name === 'AbortError'
149
+ ? `Cancelled. An already-submitted job continues; resume with lomasub download ${error.jobId ?? '<job-id>'} --output <path>.`
150
+ : error.message);
96
151
  process.exitCode = error.name === 'AbortError' ? 130 : 1;
97
152
  });
package/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export interface TranscriptionConfig {
2
+ promodel?: 'pro' | 'translate-th' | 'translate-en' | 'translate-ja' | 'translate-ko' | 'translate-zh' | 'translate-jp' | 'translate-kr' | null;
2
3
  forcealigner_level?: 'default' | 'llmsplitting' | 'none';
3
4
  min_vad_duration_seconds?: number;
4
5
  max_vad_duration_seconds?: number;
@@ -12,9 +13,6 @@ export interface TranscriptionConfig {
12
13
  speaker_clustering_drop_dialogue_percentile?: number;
13
14
  agglomeration_min_appear?: number;
14
15
  agglomolation_group_size?: number;
15
- revise_srt?: boolean;
16
- revise_speakername?: boolean;
17
- srt_revision_backend?: 'agy' | 'codex';
18
16
  [key: string]: unknown;
19
17
  }
20
18
 
@@ -31,7 +29,16 @@ export interface WaitOptions {
31
29
  pollIntervalMs?: number;
32
30
  }
33
31
 
34
- export interface TranscribeOptions extends WaitOptions {
32
+ export type ResultFormat = 'srt' | 'txt';
33
+
34
+ export interface ResultOptions {
35
+ /** `srt` (default) or `txt`: every cue joined by one space, on a single line with no newline. */
36
+ format?: ResultFormat;
37
+ /** `txt` only: keep the diarization labels (`A: ...`). Default false. */
38
+ speakers?: boolean;
39
+ }
40
+
41
+ export interface TranscribeOptions extends WaitOptions, ResultOptions {
35
42
  output?: string;
36
43
  overwrite?: boolean;
37
44
  ffmpeg?: boolean;
@@ -70,6 +77,7 @@ export interface TranscriptionResult extends JobStatus {
70
77
  };
71
78
  }
72
79
 
80
+ export const RESULT_FORMATS: readonly ResultFormat[];
73
81
  export const TRANSCRIPTION_OPTIONS: Readonly<Record<string, 'string' | 'number' | 'boolean'>>;
74
82
 
75
83
  export class LomaClient {
@@ -79,4 +87,8 @@ export class LomaClient {
79
87
  waitForJob(jobId: string, options?: WaitOptions): Promise<JobStatus>;
80
88
  transcribeFile(file: string, options?: TranscribeOptions): Promise<TranscriptionResult>;
81
89
  downloadSrt(jobId: string, output: string, options?: WaitOptions & { overwrite?: boolean }): Promise<string>;
90
+ downloadText(jobId: string, output: string, options?: WaitOptions & { overwrite?: boolean; speakers?: boolean }): Promise<string>;
91
+ getText(jobId: string, options?: { signal?: AbortSignal; speakers?: boolean }): Promise<string>;
92
+ readResult(jobId: string, options?: WaitOptions & ResultOptions): Promise<string>;
93
+ resultText(status: JobStatus, options?: ResultOptions & Pick<WaitOptions, 'signal' | 'onProgress'>): Promise<string>;
82
94
  }
package/index.js CHANGED
@@ -13,6 +13,7 @@ const run = promisify(execFile);
13
13
  // Shared with the CLI; validation and transcription defaults belong to the API.
14
14
  export const TRANSCRIPTION_OPTIONS = {
15
15
  forcealigner_level: 'string',
16
+ promodel: 'string',
16
17
  min_vad_duration_seconds: 'number',
17
18
  max_vad_duration_seconds: 'number',
18
19
  vad_threshold: 'number',
@@ -25,9 +26,6 @@ export const TRANSCRIPTION_OPTIONS = {
25
26
  speaker_clustering_drop_dialogue_percentile: 'number',
26
27
  agglomeration_min_appear: 'number',
27
28
  agglomolation_group_size: 'number',
28
- revise_srt: 'boolean',
29
- revise_speakername: 'boolean',
30
- srt_revision_backend: 'string',
31
29
  };
32
30
 
33
31
  const MIME_TYPES = {
@@ -50,6 +48,13 @@ async function checkedFetch(url, options, stage) {
50
48
  return response;
51
49
  }
52
50
 
51
+ export const RESULT_FORMATS = ['srt', 'txt'];
52
+
53
+ function resultFormat(format = 'srt') {
54
+ if (!RESULT_FORMATS.includes(format)) throw new Error(`format must be one of: ${RESULT_FORMATS.join(', ')}.`);
55
+ return format;
56
+ }
57
+
53
58
  export class LomaClient {
54
59
  constructor({ apiKey = process.env.LOMA_API_SECRET_KEY, endpoint = 'https://api.lomasub.ai' } = {}) {
55
60
  if (!apiKey) throw new Error('Set LOMA_API_SECRET_KEY or provide apiKey.');
@@ -69,6 +74,9 @@ export class LomaClient {
69
74
  async submitFile(file, options = {}) {
70
75
  const { signal, onProgress = () => {}, ffmpeg = true, config, language, duration_seconds } = options;
71
76
  signal?.throwIfAborted();
77
+ for (const key of ['revise_srt', 'revise_speakername', 'srt_revision_backend']) {
78
+ if (config && Object.hasOwn(config, key)) throw new Error(`config.${key} is not supported by the CLI/SDK.`);
79
+ }
72
80
  const original = resolve(file);
73
81
  if (!(await stat(original)).isFile()) throw new Error(`Not a regular file: ${file}`);
74
82
  let source = original;
@@ -88,10 +96,12 @@ export class LomaClient {
88
96
  }
89
97
  }
90
98
  signal?.throwIfAborted();
99
+ const size_bytes = (await stat(source)).size;
91
100
  const upload = await this.request('/presigned-url', {
92
101
  filename: basename(source),
93
102
  content_type: MIME_TYPES[extname(source).toLowerCase()] ?? 'application/octet-stream',
94
103
  duration_seconds,
104
+ size_bytes,
95
105
  }, signal);
96
106
  if (upload.method !== 'PUT' || !upload.upload_url || !upload.source_url || !upload.headers) {
97
107
  throw new Error('Invalid /presigned-url response.');
@@ -100,7 +110,7 @@ export class LomaClient {
100
110
  const stream = createReadStream(source);
101
111
  try {
102
112
  const response = await checkedFetch(upload.upload_url, {
103
- method: upload.method, headers: { ...upload.headers, 'Content-Length': String((await stat(source)).size) },
113
+ method: upload.method, headers: { ...upload.headers, 'Content-Length': String(size_bytes) },
104
114
  body: stream, duplex: 'half', signal,
105
115
  }, 'Upload');
106
116
  await response.arrayBuffer();
@@ -135,6 +145,7 @@ export class LomaClient {
135
145
 
136
146
  async transcribeFile(file, options = {}) {
137
147
  const started = performance.now();
148
+ resultFormat(options.format);
138
149
  if (options.output !== undefined && !options.overwrite) {
139
150
  try {
140
151
  await lstat(options.output);
@@ -143,7 +154,7 @@ export class LomaClient {
143
154
  }
144
155
  const job = await this.submitFile(file, options);
145
156
  const status = await this.waitForJob(job.job_id, options);
146
- const output_path = options.output === undefined ? undefined : await this.#saveSrt(status, options.output, options);
157
+ const output_path = options.output === undefined ? undefined : await this.#save(status, options.output, options);
147
158
  const total_seconds = (performance.now() - started) / 1000;
148
159
  const content_duration_seconds = [status.audio_duration_seconds, options.duration_seconds]
149
160
  .find(value => Number.isFinite(value) && value >= 0) ?? null;
@@ -155,18 +166,62 @@ export class LomaClient {
155
166
 
156
167
  async downloadSrt(jobId, output, options = {}) {
157
168
  const status = await this.waitForJob(jobId, options);
158
- return this.#saveSrt(status, output, options);
169
+ return this.#save(status, output, { ...options, format: 'srt' });
170
+ }
171
+
172
+ async downloadText(jobId, output, options = {}) {
173
+ const status = await this.waitForJob(jobId, options);
174
+ return this.#save(status, output, { ...options, format: 'txt' });
175
+ }
176
+
177
+ // The current transcript of a READY job as one line: cues joined by a space, no newline.
178
+ async getText(jobId, { signal, speakers = false } = {}) {
179
+ return (await this.#fetchResult({ job_id: jobId }, 'txt', { signal, speakers })).text();
180
+ }
181
+
182
+ // Wait for the job, then return its result as a string (`format`: 'srt' or 'txt').
183
+ async readResult(jobId, options = {}) {
184
+ resultFormat(options.format);
185
+ return this.resultText(await this.waitForJob(jobId, options), options);
186
+ }
187
+
188
+ // The result of an already READY status (e.g. what transcribeFile returned) as a string.
189
+ async resultText(status, { format, speakers, signal, onProgress = () => {} } = {}) {
190
+ format = resultFormat(format);
191
+ onProgress({ stage: 'downloading', job_id: status.job_id });
192
+ return (await this.#fetchResult(status, format, { signal, speakers })).text();
193
+ }
194
+
195
+ async #fetchResult(status, format, { signal, speakers = false }) {
196
+ const jobId = status.job_id;
197
+ if (format === 'srt') {
198
+ if (!status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
199
+ return checkedFetch(status.srt_url, { signal }, 'Download');
200
+ }
201
+ // Text is derived by the API (it follows editor saves), so it needs the bearer key.
202
+ const query = speakers ? '?speakers=true' : '';
203
+ try {
204
+ return await checkedFetch(`${this.endpoint}/jobs/${encodeURIComponent(jobId)}.txt${query}`, {
205
+ signal, headers: { Authorization: `Bearer ${this.apiKey}` },
206
+ }, 'Download');
207
+ } catch (error) {
208
+ if (error.status === 404) {
209
+ error.message = `Job ${jobId} has no text transcript: it is not READY, not yours, or the server does not support .txt yet.`;
210
+ }
211
+ throw error;
212
+ }
159
213
  }
160
214
 
161
- async #saveSrt(status, output, { signal, overwrite = false, onProgress = () => {} }) {
215
+ async #save(status, output, { signal, overwrite = false, onProgress = () => {}, format, speakers }) {
216
+ format = resultFormat(format);
162
217
  const jobId = status.job_id;
163
- if (!status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
218
+ if (format === 'srt' && !status.srt_url) throw new Error(`Job ${jobId} has no SRT URL.`);
164
219
  const target = resolve(output);
165
220
  const temporary = await mkdtemp(join(dirname(target), '.lomasub-download-'));
166
- const partial = join(temporary, 'result.srt');
221
+ const partial = join(temporary, `result.${format}`);
167
222
  try {
168
223
  onProgress({ stage: 'downloading', job_id: jobId });
169
- const response = await checkedFetch(status.srt_url, { signal }, 'Download');
224
+ const response = await this.#fetchResult(status, format, { signal, speakers });
170
225
  await pipeline(Readable.fromWeb(response.body), createWriteStream(partial), { signal });
171
226
  signal?.throwIfAborted();
172
227
  // link fails atomically if the destination exists; rename replaces it only when requested.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lomasub",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Lomasub cli is a command-line tool for creating subtitles from local audio or video in Node.js or the terminal, using lomasub.ai cloud service. ",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=20" },