lomasub 0.1.3 → 0.2.1

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 +24 -2
  2. package/cli.js +47 -13
  3. package/index.d.ts +16 -2
  4. package/index.js +58 -6
  5. package/package.json +23 -6
package/README.md CHANGED
@@ -23,10 +23,16 @@ lomasub transcribe video.mp4 --speaker-diarization --srt-max-chars 48 --srt-cue-
23
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
 
@@ -81,10 +87,14 @@ Results include API job data and whichever artifact URLs the server produced. `t
81
87
  | `waitForJob(jobId, options?)` | Poll until READY; throw with the failed stage on FAILED |
82
88
  | `transcribeFile(file, options?)` | Submit, wait, optionally save SRT, and return timing |
83
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 |
84
94
 
85
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.
86
96
 
87
- `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.
88
98
 
89
99
  After successful CLI transcription, stderr includes a final summary:
90
100
 
@@ -110,3 +120,15 @@ npm pack --dry-run
110
120
  ```
111
121
 
112
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('_', '-');
@@ -20,7 +21,7 @@ for (const [key, type] of Object.entries(TRANSCRIPTION_OPTIONS)) {
20
21
  // Display-only production defaults; omitted options still use the API's settings.
21
22
  const DEFAULT_HELP = {
22
23
  forcealigner_level: 'default; choices: default, llmsplitting, none',
23
- promodel: 'disabled (omitted/null); use pro to enable word correction',
24
+ promodel: 'disabled; pro or translate-th/en/ja/ko/zh (zh = Simplified Chinese; jp/kr aliases)',
24
25
  min_vad_duration_seconds: '10',
25
26
  max_vad_duration_seconds: '25',
26
27
  vad_threshold: '0.30',
@@ -47,10 +48,15 @@ async function main() {
47
48
  console.log(`Usage:
48
49
  lomasub transcribe <file> [options]
49
50
  lomasub download <job-id> --output <file.srt> [--overwrite]
51
+ lomasub download <job-id> --format txt [--output <file.txt>]
50
52
 
51
53
  Set LOMA_API_SECRET_KEY before running. Default endpoint: https://api.lomasub.ai
52
54
  Options:
53
- --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)
54
60
  --overwrite Replace existing output
55
61
  --no-ffmpeg Upload original without audio extraction
56
62
  --language <code> ASR language hint: th, en; server default: th
@@ -70,9 +76,15 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => {
70
76
  if (!['transcribe', 'download'].includes(command) || !input || positionals.length !== 2) {
71
77
  throw new Error('Use transcribe <file> or download <job-id>. See --help.');
72
78
  }
73
- if (command === 'download' && !values.output) throw new Error('download requires --output.');
74
- const output = resolve(values.output ?? `${input.slice(0, input.length - extname(input).length)}.srt`);
75
- 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) {
76
88
  try {
77
89
  await lstat(output);
78
90
  throw new Error(`Output exists: ${output}. Use --overwrite to replace it.`);
@@ -86,26 +98,46 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => {
86
98
  if (values[`no-${flag}`]) config[key] = false;
87
99
  else if (values[flag] !== undefined) config[key] = type === 'number' ? number(values[flag], flag) : values[flag];
88
100
  }
101
+ let jobId = command === 'download' ? input : undefined;
89
102
  const controller = new AbortController();
90
103
  const cancel = () => controller.abort();
91
104
  const settings = {
92
105
  signal: controller.signal, overwrite: values.overwrite, ffmpeg: !values['no-ffmpeg'],
106
+ format, speakers: values.speakers,
93
107
  language: values.language, filename: values.filename, config,
94
108
  duration_seconds: values['duration-seconds'] === undefined ? undefined : number(values['duration-seconds'], 'duration-seconds'),
95
- 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
+ },
96
113
  };
97
114
  process.once('SIGINT', cancel);
98
115
  process.once('SIGTERM', cancel);
99
116
  try {
100
117
  const client = new LomaClient({ endpoint: values.endpoint });
101
- if (command === 'download') console.log(await client.downloadSrt(input, output, settings));
102
- 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 {
103
127
  const result = await client.transcribeFile(input, { ...settings, output });
128
+ const text = stdout ? await client.resultText(result, settings) : undefined;
104
129
  const { content_duration_seconds, total_seconds, speed_x } = result.timing;
105
- const format = (value, unit) => value === null ? 'unknown' : `${value.toFixed(2)}${unit}`;
106
- console.error(`Content: ${format(content_duration_seconds, 's')} | Total: ${format(total_seconds, 's')} | Speed: ${format(speed_x, 'x realtime')}`);
107
- 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);
108
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;
109
141
  } finally {
110
142
  process.removeListener('SIGINT', cancel);
111
143
  process.removeListener('SIGTERM', cancel);
@@ -113,6 +145,8 @@ ${Object.entries(TRANSCRIPTION_OPTIONS).map(([key, type]) => {
113
145
  }
114
146
 
115
147
  main().catch(error => {
116
- 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);
117
151
  process.exitCode = error.name === 'AbortError' ? 130 : 1;
118
152
  });
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface TranscriptionConfig {
2
- promodel?: 'pro' | null;
2
+ promodel?: 'pro' | 'translate-th' | 'translate-en' | 'translate-ja' | 'translate-ko' | 'translate-zh' | 'translate-jp' | 'translate-kr' | null;
3
3
  forcealigner_level?: 'default' | 'llmsplitting' | 'none';
4
4
  min_vad_duration_seconds?: number;
5
5
  max_vad_duration_seconds?: number;
@@ -29,7 +29,16 @@ export interface WaitOptions {
29
29
  pollIntervalMs?: number;
30
30
  }
31
31
 
32
- 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 {
33
42
  output?: string;
34
43
  overwrite?: boolean;
35
44
  ffmpeg?: boolean;
@@ -68,6 +77,7 @@ export interface TranscriptionResult extends JobStatus {
68
77
  };
69
78
  }
70
79
 
80
+ export const RESULT_FORMATS: readonly ResultFormat[];
71
81
  export const TRANSCRIPTION_OPTIONS: Readonly<Record<string, 'string' | 'number' | 'boolean'>>;
72
82
 
73
83
  export class LomaClient {
@@ -77,4 +87,8 @@ export class LomaClient {
77
87
  waitForJob(jobId: string, options?: WaitOptions): Promise<JobStatus>;
78
88
  transcribeFile(file: string, options?: TranscribeOptions): Promise<TranscriptionResult>;
79
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>;
80
94
  }
package/index.js CHANGED
@@ -48,6 +48,13 @@ async function checkedFetch(url, options, stage) {
48
48
  return response;
49
49
  }
50
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
+
51
58
  export class LomaClient {
52
59
  constructor({ apiKey = process.env.LOMA_API_SECRET_KEY, endpoint = 'https://api.lomasub.ai' } = {}) {
53
60
  if (!apiKey) throw new Error('Set LOMA_API_SECRET_KEY or provide apiKey.');
@@ -138,6 +145,7 @@ export class LomaClient {
138
145
 
139
146
  async transcribeFile(file, options = {}) {
140
147
  const started = performance.now();
148
+ resultFormat(options.format);
141
149
  if (options.output !== undefined && !options.overwrite) {
142
150
  try {
143
151
  await lstat(options.output);
@@ -146,7 +154,7 @@ export class LomaClient {
146
154
  }
147
155
  const job = await this.submitFile(file, options);
148
156
  const status = await this.waitForJob(job.job_id, options);
149
- 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);
150
158
  const total_seconds = (performance.now() - started) / 1000;
151
159
  const content_duration_seconds = [status.audio_duration_seconds, options.duration_seconds]
152
160
  .find(value => Number.isFinite(value) && value >= 0) ?? null;
@@ -158,18 +166,62 @@ export class LomaClient {
158
166
 
159
167
  async downloadSrt(jobId, output, options = {}) {
160
168
  const status = await this.waitForJob(jobId, options);
161
- 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
+ }
162
213
  }
163
214
 
164
- async #saveSrt(status, output, { signal, overwrite = false, onProgress = () => {} }) {
215
+ async #save(status, output, { signal, overwrite = false, onProgress = () => {}, format, speakers }) {
216
+ format = resultFormat(format);
165
217
  const jobId = status.job_id;
166
- 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.`);
167
219
  const target = resolve(output);
168
220
  const temporary = await mkdtemp(join(dirname(target), '.lomasub-download-'));
169
- const partial = join(temporary, 'result.srt');
221
+ const partial = join(temporary, `result.${format}`);
170
222
  try {
171
223
  onProgress({ stage: 'downloading', job_id: jobId });
172
- const response = await checkedFetch(status.srt_url, { signal }, 'Download');
224
+ const response = await this.#fetchResult(status, format, { signal, speakers });
173
225
  await pipeline(Readable.fromWeb(response.body), createWriteStream(partial), { signal });
174
226
  signal?.throwIfAborted();
175
227
  // link fails atomically if the destination exists; rename replaces it only when requested.
package/package.json CHANGED
@@ -1,12 +1,29 @@
1
1
  {
2
2
  "name": "lomasub",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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
- "engines": { "node": ">=20" },
7
- "exports": { ".": { "types": "./index.d.ts", "default": "./index.js" } },
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./index.js"
13
+ }
14
+ },
8
15
  "types": "./index.d.ts",
9
- "bin": { "lomasub": "./cli.js" },
10
- "files": ["index.js", "index.d.ts", "cli.js", "README.md"],
11
- "scripts": { "test": "node --test", "check": "node --check index.js && node --check cli.js" }
16
+ "bin": {
17
+ "lomasub": "cli.js"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "index.d.ts",
22
+ "cli.js",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test",
27
+ "check": "node --check index.js && node --check cli.js"
28
+ }
12
29
  }