From e0e345528163e67ff6c622143245de09c094c0c6 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Sun, 23 Aug 2026 21:51:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(ai):=20STT=20=E2=80=94=20=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BF=D0=BE=20content-type?= =?UTF-8?q?,=20webm/mp4=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20voice.dat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI определяет формат аудио по расширению имени файла в multipart-части, а не по её content-type. Back слал нейтральное filename="voice.dat": wav проходил случайно (узнаваемый заголовок), а webm/opus и mp4 из браузерного MediaRecorder получали 400 «Could not detect audio format» → 502 upstream_error → тост «Распознавание речи временно недоступно». Расширение выводится из mime (параметры codecs отбрасываются); неизвестный тип — .wav. Тесты на маппинг и подстановку в multipart. --- src/logic/logic_ai_transcribe.erl | 34 ++++++++++++++++++++++--- test/unit/logic_ai_transcribe_tests.erl | 30 ++++++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/logic/logic_ai_transcribe.erl b/src/logic/logic_ai_transcribe.erl index 96b0a46..c0ac7c2 100644 --- a/src/logic/logic_ai_transcribe.erl +++ b/src/logic/logic_ai_transcribe.erl @@ -11,7 +11,7 @@ -export([transcribe/3, max_bytes/0, ensure/0]). %% Экспортировано для тестов и хэндлера. --export([parse_provider_response/2, build_multipart/4]). +-export([parse_provider_response/2, build_multipart/4, file_name/1]). -define(RL_TABLE, eventhub_stt_rl). -define(UP, eventhub_stt_upstream). @@ -127,16 +127,44 @@ env_int(Name, Default) -> call_provider(xai, Url, Key, Audio, CType) -> %% xAI: multipart без опциональных полей — язык определяется авто %% (format=true требует явный language и отключает автоопределение). - Body = build_multipart([], Audio, <<"voice.dat">>, CType), + Body = build_multipart([], Audio, file_name(CType), CType), post_and_parse(xai, Url, Key, Body); call_provider(openai, Url, Key, Audio, CType) -> Model = case env_bin("STT_MODEL") of <<>> -> <<"whisper-1">>; M -> M end, - Body = build_multipart([{<<"model">>, Model}], Audio, <<"voice.dat">>, CType), + Body = build_multipart([{<<"model">>, Model}], Audio, file_name(CType), CType), post_and_parse(openai, Url, Key, Body). +%% Апстрим определяет формат аудио по расширению имени файла в +%% multipart-части (не по её content-type); нейтральное voice.dat +%% даёт 400 «Could not detect audio format» на webm/mp4 из +%% MediaRecorder. Расширение выводим из content-type (параметры +%% вида ;codecs=opus отбрасываем); неизвестный тип — .wav. +-spec file_name(binary()) -> binary(). +file_name(ContentType) -> + Mime = case binary:split(ContentType, <<";">>) of + [M | _] -> string:trim(M); + [] -> <<>> + end, + case Mime of + <<"audio/webm">> -> <<"voice.webm">>; + <<"audio/mp4">> -> <<"voice.mp4">>; + <<"audio/aac">> -> <<"voice.aac">>; + <<"audio/mpeg">> -> <<"voice.mp3">>; + <<"audio/mp3">> -> <<"voice.mp3">>; + <<"audio/ogg">> -> <<"voice.ogg">>; + <<"audio/opus">> -> <<"voice.opus">>; + <<"audio/flac">> -> <<"voice.flac">>; + <<"audio/m4a">> -> <<"voice.m4a">>; + <<"audio/x-m4a">> -> <<"voice.m4a">>; + <<"audio/wav">> -> <<"voice.wav">>; + <<"audio/wave">> -> <<"voice.wav">>; + <<"audio/x-wav">> -> <<"voice.wav">>; + _ -> <<"voice.wav">> + end. + post_and_parse(Provider, Url, Key, Body) -> Headers = [{"authorization", binary_to_list(<<"Bearer ", Key/binary>>)}], CTypeHdr = "multipart/form-data; boundary=" ++ binary_to_list(boundary(Body)), diff --git a/test/unit/logic_ai_transcribe_tests.erl b/test/unit/logic_ai_transcribe_tests.erl index 31a9637..ece0858 100644 --- a/test/unit/logic_ai_transcribe_tests.erl +++ b/test/unit/logic_ai_transcribe_tests.erl @@ -36,7 +36,9 @@ logic_ai_transcribe_test_() -> {"per-user rate limit", fun test_user_rate_limit/0}, {"global upstream throttle", fun test_global_throttle/0}, {"invalid audio rejected", fun test_invalid_audio/0}, - {"multipart layout", fun test_multipart/0} + {"multipart layout", fun test_multipart/0}, + {"file name from content-type", fun test_file_name/0}, + {"multipart carries typed file name", fun test_multipart_file_name/0} ]}. %% Стаб апстрима: фиксирует запрос и отвечает успешным JSON. @@ -59,7 +61,10 @@ test_xai_success() -> ?assertMatch({_, _}, binary:match(Body, <<"name=\"file\"">>)), ?assertMatch({_, _}, binary:match(Body, <<"audio-bytes">>)), %% Без поля format — язык определяется авто (format=true требует language). - ?assertEqual(nomatch, binary:match(Body, <<"name=\"format\"">>)). + ?assertEqual(nomatch, binary:match(Body, <<"name=\"format\"">>)), + %% webm обязан уйти с расширением .webm (апстрим определяет формат + %% по имени файла, voice.dat даёт 400). + ?assertMatch({_, _}, binary:match(Body, <<"filename=\"voice.webm\"">>)). test_openai_adapter() -> os:putenv("STT_PROVIDER", "openai"), @@ -124,3 +129,24 @@ test_multipart() -> ?assert(binary:match(Body, <<"--\r\n">>) =/= nomatch), %% Завершающий boundary в конце тела: ----\r\n. ?assertEqual(<<"--\r\n">>, binary:part(Body, byte_size(Body), -4)). + +%% Расширение имени файла выводится из mime; параметры codecs +%% отбрасываются (браузер шлёт audio/webm;codecs=opus). +test_file_name() -> + ?assertEqual(<<"voice.webm">>, logic_ai_transcribe:file_name(<<"audio/webm">>)), + ?assertEqual(<<"voice.webm">>, + logic_ai_transcribe:file_name(<<"audio/webm;codecs=opus">>)), + ?assertEqual(<<"voice.mp4">>, logic_ai_transcribe:file_name(<<"audio/mp4">>)), + ?assertEqual(<<"voice.wav">>, logic_ai_transcribe:file_name(<<"audio/wav">>)), + ?assertEqual(<<"voice.wav">>, logic_ai_transcribe:file_name(<<"audio/x-wav">>)), + ?assertEqual(<<"voice.mp3">>, logic_ai_transcribe:file_name(<<"audio/mpeg">>)), + ?assertEqual(<<"voice.m4a">>, logic_ai_transcribe:file_name(<<"audio/x-m4a">>)), + ?assertEqual(<<"voice.wav">>, + logic_ai_transcribe:file_name(<<"application/octet-stream">>)). + +%% transcribe() подставляет типизированное имя файла в multipart. +test_multipart_file_name() -> + ?assertEqual({ok, <<"привет">>, <<"ru">>}, + logic_ai_transcribe:transcribe(<<"u1">>, <<"audio-bytes">>, <<"audio/mp4">>)), + {_Url, _Headers, _CType, Body} = get(stt_call), + ?assertMatch({_, _}, binary:match(Body, <<"filename=\"voice.mp4\"">>)).