diff --git a/examples/cli/main.cpp b/examples/cli/main.cpp index df820403..1258b19c 100644 --- a/examples/cli/main.cpp +++ b/examples/cli/main.cpp @@ -204,13 +204,14 @@ struct cli_args { bool quiet = false; bool list_devices = false; // --list-devices: print devices and exit bool batch_jsonl = false; // --batch-jsonl: output JSONL - int repeat = 1; - int n_threads = 0; // 0 = library default (all cores) - int n_ctx = 0; // 0 = model's true max; >0 lowers the cap - transcribe_kv_type kv_type = TRANSCRIBE_KV_TYPE_AUTO; - transcribe_backend_request backend = TRANSCRIBE_BACKEND_AUTO; - int gpu_device = 0; // --device N: 0 = auto, >0 = registry index - transcribe_timestamp_kind timestamps = TRANSCRIBE_TIMESTAMPS_AUTO; + std::string output_path; // -o/--output: write raw text here + int repeat = 1; + int n_threads = 0; // 0 = library default (all cores) + int n_ctx = 0; // 0 = model's true max; >0 lowers the cap + transcribe_kv_type kv_type = TRANSCRIBE_KV_TYPE_AUTO; + transcribe_backend_request backend = TRANSCRIBE_BACKEND_AUTO; + int gpu_device = 0; // --device N: 0 = auto, >0 = registry index + transcribe_timestamp_kind timestamps = TRANSCRIBE_TIMESTAMPS_AUTO; // Whisper-family knobs. Ignored for non-Whisper models. std::string initial_prompt; // --initial-prompt TEXT @@ -282,6 +283,7 @@ void print_usage(const char * argv0) { " --target-language ISO target language for translation (e.g. de, es, fr)\n" " -q, --quiet suppress library log output\n" " -r, --repeat N run N times per file (benchmark)\n" + " -o, --output PATH write transcribed text to PATH (stdout unchanged)\n" " --threads N CPU threads (default: all cores)\n" " --n-ctx N session context/KV cap in tokens (bounds decoder\n" " KV memory; cannot extend the model): 0 = model\n" @@ -528,6 +530,12 @@ bool parse_args(int argc, char ** argv, cli_args & out) { std::fprintf(stderr, "error: --batch-size must be >= 0\n"); return false; } + } else if (a == "-o" || a == "--output") { + const char * v = take_value(a.c_str()); + if (!v) { + return false; + } + out.output_path = v; } else if (a == "--initial-prompt") { const char * v = take_value(a.c_str()); if (!v) { @@ -688,6 +696,23 @@ void log_cb(transcribe_log_level level, const char * msg, void * userdata) { std::fprintf(stderr, "%s %s%s", prefix, msg, (msg && *msg && msg[std::strlen(msg) - 1] == '\n') ? "" : "\n"); } +bool write_output_file(std::ofstream * output, const std::string & path, const char * text) { + if (output == nullptr) { + return true; + } + const char * value = text != nullptr ? text : ""; + *output << value; + if (value[0] == '\0' || value[std::strlen(value) - 1] != '\n') { + *output << '\n'; + } + output->flush(); + if (!*output) { + std::fprintf(stderr, "error: cannot write %s\n", path.c_str()); + return false; + } + return true; +} + } // namespace int main(int argc, char ** argv) { @@ -713,6 +738,18 @@ int main(int argc, char ** argv) { transcribe_log_set(log_cb, nullptr); } + std::ofstream output_file; + std::ofstream * output = nullptr; + if (!args.output_path.empty()) { + output_file.open(args.output_path, std::ios::binary | std::ios::trunc); + if (!output_file) { + std::fprintf(stderr, "error: cannot open %s for writing\n", args.output_path.c_str()); + return EXIT_FAILURE; + } + output = &output_file; + } + bool output_ok = true; + // Batch mode: --batch reads a file list, one wav path per line. Loads // the model ONCE and reuses the context across all files. Outputs one // JSONL line per file to stdout when --batch-jsonl is set, otherwise @@ -957,6 +994,7 @@ int main(int argc, char ** argv) { std::printf(" ERROR: %s\n", transcribe_status_string(ust)); } } + output_ok = write_output_file(output, args.output_path, text) && output_ok; std::fflush(stdout); } } @@ -1095,6 +1133,7 @@ int main(int argc, char ** argv) { std::printf(" ERROR: %s\n", transcribe_status_string(run_st)); } } + output_ok = write_output_file(output, args.output_path, text) && output_ok; std::fflush(stdout); } } @@ -1108,7 +1147,7 @@ int main(int argc, char ** argv) { transcribe_model_free(model); // OUTPUT_TRUNCATED is result-bearing and does not fail the batch, but // hard per-utterance failures must remain visible to automation. - return n_fail > 0 ? EXIT_FAILURE : EXIT_SUCCESS; + return n_fail > 0 || !output_ok ? EXIT_FAILURE : EXIT_SUCCESS; } // Single-file mode. @@ -1341,6 +1380,7 @@ int main(int argc, char ** argv) { if (result_present) { const char * text = transcribe_full_text(ctx); std::printf("text: %s\n", (text && *text) ? text : "(empty)"); + output_ok = write_output_file(output, args.output_path, text) && output_ok; // A truncated decode hit the model's context/output budget before // end-of-stream; the text above is incomplete. @@ -1418,7 +1458,7 @@ int main(int argc, char ** argv) { transcribe_session_free(ctx); transcribe_model_free(model); - if (run_st != TRANSCRIBE_OK) { + if (run_st != TRANSCRIBE_OK || !output_ok) { return EXIT_FAILURE; } } else { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bec7501c..b463c276 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1234,4 +1234,11 @@ if(TRANSCRIBE_BUILD_EXAMPLES) ${CMAKE_SOURCE_DIR}/samples/jfk.wav) set_tests_properties(transcribe_cli_smoke PROPERTIES PASS_REGULAR_EXPRESSION "duration:") + + add_test(NAME transcribe_cli_output_smoke + COMMAND ${CMAKE_COMMAND} + -DCLI=$ + -DWAV=${CMAKE_SOURCE_DIR}/samples/jfk.wav + -DTEST_DIR=${CMAKE_CURRENT_BINARY_DIR}/cli-output-smoke + -P ${CMAKE_CURRENT_SOURCE_DIR}/cli_output_smoke.cmake) endif() diff --git a/tests/cli_output_smoke.cmake b/tests/cli_output_smoke.cmake new file mode 100644 index 00000000..699b056a --- /dev/null +++ b/tests/cli_output_smoke.cmake @@ -0,0 +1,36 @@ +foreach(_var CLI WAV TEST_DIR) + if(NOT DEFINED ${_var} OR "${${_var}}" STREQUAL "") + message(FATAL_ERROR "${_var} is required") + endif() +endforeach() + +file(REMOVE_RECURSE "${TEST_DIR}") +file(MAKE_DIRECTORY "${TEST_DIR}") + +set(_output "${TEST_DIR}/transcript.txt") +file(WRITE "${_output}" "stale output\n") +execute_process( + COMMAND "${CLI}" -q -o "${_output}" "${WAV}" + RESULT_VARIABLE _result + OUTPUT_VARIABLE _stdout + ERROR_VARIABLE _stderr) +if(NOT _result EQUAL 0) + message(FATAL_ERROR "output command failed (${_result}):\n${_stdout}\n${_stderr}") +endif() +file(SIZE "${_output}" _output_size) +if(NOT _output_size EQUAL 0) + message(FATAL_ERROR "--output did not truncate stale output") +endif() + +set(_bad_output "${TEST_DIR}/missing/transcript.txt") +execute_process( + COMMAND "${CLI}" -q -o "${_bad_output}" "${WAV}" + RESULT_VARIABLE _bad_result + OUTPUT_VARIABLE _bad_stdout + ERROR_VARIABLE _bad_stderr) +if(_bad_result EQUAL 0) + message(FATAL_ERROR "unwritable output path unexpectedly succeeded") +endif() +if(EXISTS "${_bad_output}") + message(FATAL_ERROR "unwritable output path unexpectedly created a file") +endif()