fix(ai): STT — имя файла по content-type, webm/mp4 уходили как voice.dat
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.
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
-export([transcribe/3, max_bytes/0, ensure/0]).
|
-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(RL_TABLE, eventhub_stt_rl).
|
||||||
-define(UP, eventhub_stt_upstream).
|
-define(UP, eventhub_stt_upstream).
|
||||||
@@ -127,16 +127,44 @@ env_int(Name, Default) ->
|
|||||||
call_provider(xai, Url, Key, Audio, CType) ->
|
call_provider(xai, Url, Key, Audio, CType) ->
|
||||||
%% xAI: multipart без опциональных полей — язык определяется авто
|
%% xAI: multipart без опциональных полей — язык определяется авто
|
||||||
%% (format=true требует явный language и отключает автоопределение).
|
%% (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);
|
post_and_parse(xai, Url, Key, Body);
|
||||||
call_provider(openai, Url, Key, Audio, CType) ->
|
call_provider(openai, Url, Key, Audio, CType) ->
|
||||||
Model = case env_bin("STT_MODEL") of
|
Model = case env_bin("STT_MODEL") of
|
||||||
<<>> -> <<"whisper-1">>;
|
<<>> -> <<"whisper-1">>;
|
||||||
M -> M
|
M -> M
|
||||||
end,
|
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).
|
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) ->
|
post_and_parse(Provider, Url, Key, Body) ->
|
||||||
Headers = [{"authorization", binary_to_list(<<"Bearer ", Key/binary>>)}],
|
Headers = [{"authorization", binary_to_list(<<"Bearer ", Key/binary>>)}],
|
||||||
CTypeHdr = "multipart/form-data; boundary=" ++ binary_to_list(boundary(Body)),
|
CTypeHdr = "multipart/form-data; boundary=" ++ binary_to_list(boundary(Body)),
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ logic_ai_transcribe_test_() ->
|
|||||||
{"per-user rate limit", fun test_user_rate_limit/0},
|
{"per-user rate limit", fun test_user_rate_limit/0},
|
||||||
{"global upstream throttle", fun test_global_throttle/0},
|
{"global upstream throttle", fun test_global_throttle/0},
|
||||||
{"invalid audio rejected", fun test_invalid_audio/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.
|
%% Стаб апстрима: фиксирует запрос и отвечает успешным JSON.
|
||||||
@@ -59,7 +61,10 @@ test_xai_success() ->
|
|||||||
?assertMatch({_, _}, binary:match(Body, <<"name=\"file\"">>)),
|
?assertMatch({_, _}, binary:match(Body, <<"name=\"file\"">>)),
|
||||||
?assertMatch({_, _}, binary:match(Body, <<"audio-bytes">>)),
|
?assertMatch({_, _}, binary:match(Body, <<"audio-bytes">>)),
|
||||||
%% Без поля format — язык определяется авто (format=true требует language).
|
%% Без поля 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() ->
|
test_openai_adapter() ->
|
||||||
os:putenv("STT_PROVIDER", "openai"),
|
os:putenv("STT_PROVIDER", "openai"),
|
||||||
@@ -124,3 +129,24 @@ test_multipart() ->
|
|||||||
?assert(binary:match(Body, <<"--\r\n">>) =/= nomatch),
|
?assert(binary:match(Body, <<"--\r\n">>) =/= nomatch),
|
||||||
%% Завершающий boundary в конце тела: --<B>--\r\n.
|
%% Завершающий boundary в конце тела: --<B>--\r\n.
|
||||||
?assertEqual(<<"--\r\n">>, binary:part(Body, byte_size(Body), -4)).
|
?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\"">>)).
|
||||||
|
|||||||
Reference in New Issue
Block a user