3636// this is a belt-and-braces guarantee against a future bug or parallel invoker).
3737
3838#include " whisper.h"
39+ #include " ctc_aligner.h"
3940
4041#include < algorithm>
4142#include < atomic>
@@ -259,6 +260,21 @@ double to_original_sec(int64_t cs, const std::vector<Kept>& kept) {
259260 return (kept.back ().from + kept.back ().len ) / 16000.0 ;
260261}
261262
263+ std::string base64 (const void * data, size_t n) {
264+ static const char * abc = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" ;
265+ const auto * p = static_cast <const unsigned char *>(data);
266+ std::string out;
267+ out.reserve ((n + 2 ) / 3 * 4 );
268+ for (size_t i = 0 ; i < n; i += 3 ) {
269+ const uint32_t v = (p[i] << 16 ) | (i + 1 < n ? p[i + 1 ] << 8 : 0 ) | (i + 2 < n ? p[i + 2 ] : 0 );
270+ out += abc[(v >> 18 ) & 63 ];
271+ out += abc[(v >> 12 ) & 63 ];
272+ out += i + 1 < n ? abc[(v >> 6 ) & 63 ] : ' =' ;
273+ out += i + 2 < n ? abc[v & 63 ] : ' =' ;
274+ }
275+ return out;
276+ }
277+
262278} // namespace
263279
264280int main (int argc, char ** argv) {
@@ -376,13 +392,15 @@ int main(int argc, char** argv) {
376392 res.set_content (probe.dump (), " application/json" );
377393 });
378394
379- // POST /inference — multipart form with `file` (WAV) + `language` + `response_format`.
380- svr.Post (" /inference" , [&](const httplib::Request& req, httplib::Response& res) {
395+ // The upload of /inference and /emissions: a 16 kHz mono PCM16 WAV in the
396+ // multipart field `file`. False after answering 400.
397+ const auto read_upload = [](const httplib::Request& req, httplib::Response& res,
398+ std::vector<float >& pcm) -> bool {
381399 auto it = req.files .find (" file" );
382400 if (it == req.files .end ()) {
383401 res.status = 400 ;
384402 res.set_content (R"( {"error":"missing 'file' form field"})" , " application/json" );
385- return ;
403+ return false ;
386404 }
387405 const auto & file_entry = it->second ;
388406
@@ -404,23 +422,29 @@ int main(int argc, char** argv) {
404422 out.write (file_entry.content .data (),
405423 static_cast <std::streamsize>(file_entry.content .size ()));
406424 }
407- std::vector<float > pcm;
408425 int sample_rate = 0 , channels = 0 ;
409426 const bool ok = read_wav_pcm16 (tmp_wav, pcm, sample_rate, channels);
410427 std::error_code ec;
411428 std::filesystem::remove (tmp_wav, ec);
412429 if (!ok) {
413430 res.status = 400 ;
414431 res.set_content (R"( {"error":"failed to parse WAV"})" , " application/json" );
415- return ;
432+ return false ;
416433 }
417434 if (sample_rate != 16000 || channels != 1 ) {
418435 res.status = 400 ;
419436 res.set_content (
420437 R"( {"error":"expected 16 kHz mono PCM16 WAV"})" ,
421438 " application/json" );
422- return ;
439+ return false ;
423440 }
441+ return true ;
442+ };
443+
444+ // POST /inference — multipart form with `file` (WAV) + `language` + `response_format`.
445+ svr.Post (" /inference" , [&](const httplib::Request& req, httplib::Response& res) {
446+ std::vector<float > pcm;
447+ if (!read_upload (req, res, pcm)) return ;
424448
425449 // language param
426450 std::string language = " auto" ;
@@ -669,6 +693,77 @@ int main(int argc, char** argv) {
669693 res.set_content (reply.dump (), " application/json" );
670694 });
671695
696+ // POST /emissions — the CTC aligner's acoustic pass (issue #948, phase 3).
697+ // Multipart form: `file` (the same WAV as /inference), `model` (path of a
698+ // wav2vec2 GGUF, see ctc_aligner.h) and `regions` (JSON [[start_s, end_s], ...]).
699+ // Answers the model's vocabulary and, per region, base64 float32 log-probs
700+ // [frames x vocab]; frame i of a region sees the audio from
701+ // `start + i * stride_s` for `receptive_s`. The forced alignment itself runs
702+ // on the Node side (electron/stt/ctcAlign.ts). The model stays loaded until a
703+ // request names another one.
704+ CtcModelPtr aligner;
705+ std::string aligner_path;
706+ svr.Post (" /emissions" , [&](const httplib::Request& req, httplib::Response& res) {
707+ std::vector<float > pcm;
708+ if (!read_upload (req, res, pcm)) return ;
709+ const std::string model = req.get_file_value (" model" ).content ;
710+ nlohmann::json regions = nlohmann::json::parse (req.get_file_value (" regions" ).content , nullptr , false );
711+ if (model.empty () || !regions.is_array ()) {
712+ res.status = 400 ;
713+ res.set_content (R"( {"error":"need 'model' and a JSON 'regions' array"})" , " application/json" );
714+ return ;
715+ }
716+ const std::lock_guard<std::mutex> lk (infer_mu);
717+ const auto t0 = std::chrono::steady_clock::now ();
718+ if (!aligner || aligner_path != model) {
719+ aligner.reset ();
720+ std::string err;
721+ aligner = ctc_load (model, cparams.use_gpu , threads, err);
722+ if (!aligner) {
723+ log (" aligner: " + err);
724+ res.status = 500 ;
725+ res.set_content (nlohmann::json{{" error" , " aligner: " + err}}.dump (), " application/json" );
726+ return ;
727+ }
728+ aligner_path = model;
729+ log (" aligner loaded on " + ctc_device (*aligner) + " : " + model);
730+ }
731+ const CtcModelInfo& info = ctc_info (*aligner);
732+ nlohmann::json out_regions = nlohmann::json::array ();
733+ const int64_t n_pcm = static_cast <int64_t >(pcm.size ());
734+ for (const auto & r : regions) {
735+ if (!r.is_array () || r.size () != 2 || !r[0 ].is_number () || !r[1 ].is_number ()) continue ;
736+ const int64_t from = std::clamp<int64_t >(std::llround (r[0 ].get <double >() * 16000.0 ), 0 , n_pcm);
737+ const int64_t to = std::clamp<int64_t >(std::llround (r[1 ].get <double >() * 16000.0 ), from, n_pcm);
738+ std::vector<float > lp;
739+ int frames = 0 ;
740+ std::string err;
741+ if (!ctc_emissions (*aligner, pcm.data () + from, static_cast <size_t >(to - from), lp, frames, err)) {
742+ log (" aligner: " + err);
743+ res.status = 500 ;
744+ res.set_content (nlohmann::json{{" error" , " aligner: " + err}}.dump (), " application/json" );
745+ return ;
746+ }
747+ out_regions.push_back ({
748+ {" start" , from / 16000.0 },
749+ {" frames" , frames},
750+ {" logprobs" , base64 (lp.data (), lp.size () * sizeof (float ))},
751+ });
752+ }
753+ const double elapsed_s = std::chrono::duration<double >(std::chrono::steady_clock::now () - t0).count ();
754+ nlohmann::json reply = {
755+ {" vocab" , info.vocab },
756+ {" blank" , info.blank },
757+ {" languages" , info.languages },
758+ {" stride_s" , info.stride / 16000.0 },
759+ {" receptive_s" , info.receptive_field / 16000.0 },
760+ {" device" , ctc_device (*aligner)},
761+ {" elapsed_s" , elapsed_s},
762+ {" regions" , std::move (out_regions)},
763+ };
764+ res.set_content (reply.dump (), " application/json" );
765+ });
766+
672767 // ---- bind + listen ----
673768 int bound_port = port;
674769 if (bound_port == 0 ) {
0 commit comments