Bundle mission speech with an admin mute option and fix live audio downloads
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Check the complete authored voice bank and its offline default routing."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
from welcome_voice import validate_wav
|
||||
from source_bundle import build
|
||||
|
||||
manifest = json.loads((ROOT / 'docs/bundled-speech.json').read_text())
|
||||
assets = json.loads((ROOT / 'docs/bundled-speech-assets.json').read_text())
|
||||
assert len(manifest['clips']) == len(assets['clips']) == 37
|
||||
soldiers = (ROOT / 'addons/core/Scripts/Game/ERR/10_AI/ERR_SoldierSpeech.c').read_text()
|
||||
expected = dict(re.findall(r'case ([\w.]+): return "([^"]+)";', soldiers))
|
||||
assert {c['kind']: c['text'] for c in manifest['clips'] if c['category'] == 'soldier'} == expected
|
||||
extraction = (ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_ExtractionService.c').read_text()
|
||||
radio = set(re.findall(r'Stage\(\d+, "([^"]+)"\)', extraction) + re.findall(r'inbound = "([^"]+)"', extraction))
|
||||
assert {c['text'] for c in manifest['clips'] if c['category'] == 'radio'} == radio
|
||||
catalog = (ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_BakedSpeech.c').read_text()
|
||||
for clip in assets['clips']:
|
||||
path = ROOT / 'addons/core/Sounds/ERR/Speech' / (clip['id'] + '.wav')
|
||||
audio = validate_wav(path.read_bytes())
|
||||
assert hashlib.sha256(audio).hexdigest() == clip['sha256']
|
||||
graph = path.with_suffix('.acp').read_text()
|
||||
assert ('spatialFactor 1' in graph) == (clip['category'] == 'soldier')
|
||||
assert 'name "ERR_SPEECH"' in graph and '"Infinite loop" 0' in graph
|
||||
assert clip['graph'] in catalog
|
||||
assert f'case "baked:{clip["id"]}":' in catalog
|
||||
for resource in (path, path.with_suffix('.acp')):
|
||||
meta = resource.with_name(resource.name + '.meta').read_text()
|
||||
assert resource.relative_to(ROOT / 'addons/core').as_posix() in meta
|
||||
assert all(f' {platform}' in meta for platform in ('PC', 'XBOX_SERIES', 'PS5', 'HEADLESS'))
|
||||
_, bundle = build(ROOT)
|
||||
files = bundle['files']
|
||||
for clip in assets['clips']:
|
||||
for suffix in ('.wav', '.wav.meta', '.acp', '.acp.meta'):
|
||||
assert 'addons/core/Sounds/ERR/Speech/' + clip['id'] + suffix in files
|
||||
print('PASS: all 37 fixed messages have validated exact audio, native graph identities and source-bundle inclusion')
|
||||
@@ -6,6 +6,7 @@ rules=body(p,'static string Text(')+body(p,'static bool Near(')
|
||||
names=re.findall(r'case ECommunicationType\.(\w+)',rules)
|
||||
rules=rules.replace('ECommunicationType.','ECommunicationType::')
|
||||
s='\n'.join(body(p,x) for x in ['bool Current()', 'void Prepared(', 'bool Speak(', 'bool ReadyClip(', 'bool Clip('])
|
||||
s=s.replace('ERR_BakedSpeech.', 'ERR_BakedSpeech::')
|
||||
s=s.replace('out string url','string& url').replace('array<SCR_PlayerController> listeners,','Array<SCR_PlayerController*> listeners,')
|
||||
s=s.replace('protected ','').replace('null','nullptr').replace('ref ','')
|
||||
for typ in ['IEntity','SCR_AITalkRequest','VoNComponent','RplComponent','SCR_PlayerController','ERR_SpeechService']:
|
||||
@@ -34,6 +35,7 @@ namespace ECommunicationType{enum{'''+','.join(names)+r''',REPORT_CONTACT=100,RE
|
||||
struct ERR_SoldierSpeechRules{
|
||||
'''+rules+r'''
|
||||
};
|
||||
bool baked=false;struct ERR_BakedSpeech{static string Soldier(int kind){return baked&&!ERR_SoldierSpeechRules::Text(kind).IsEmpty()?"baked:line":"";}};
|
||||
namespace ECommMethod{enum{DIRECT,RADIO};}
|
||||
struct VoNComponent{int method=ECommMethod::DIRECT;int GetCommMethod(){return method;}};
|
||||
struct SCR_AITalkRequest{int m_eCommType=ECommunicationType::REPORT_MOVING;};
|
||||
@@ -43,7 +45,7 @@ namespace ERR_Actors{bool CanFight(IEntity*a){return a&&a->fit;}bool Player(IEnt
|
||||
float Distance(float a,float b){return std::abs(a-b);}
|
||||
struct ERR_SoldierSpeechPreparation{void*Server=nullptr;int Kind=0;};
|
||||
struct ERR_SpeechService{bool ready=true,idle=true,accept=true;int requests=0;string endpoint="bridge",text;bool Ready(){return ready;}string CacheNamespace(){return endpoint;}bool IdleFor(float n){assert(n==65000);return idle;}bool Request(string t,ERR_SoldierSpeechPreparation*){requests++;text=t;return accept;}};
|
||||
struct ERR_GameMode{static ERR_GameMode*live;bool active=true;ERR_SpeechService voice;bool Active(){return active;}auto ERR_Speech(){return &voice;}static auto Get(){return live;}};ERR_GameMode*ERR_GameMode::live=nullptr;
|
||||
struct ERR_GameMode{static ERR_GameMode*live;bool active=true,recordings=true;bool ERR_RecordingsEnabled(){return recordings;}ERR_SpeechService voice;bool Active(){return active;}auto ERR_Speech(){return &voice;}static auto Get(){return live;}};ERR_GameMode*ERR_GameMode::live=nullptr;
|
||||
struct SCR_PlayerController{IEntity*body=nullptr;bool ready=false;int preparations=0,delivered=0;IEntity*GetControlledEntity(){return body;}bool ERR_SoldierClipReady(ERR_GameMode*,int,string){return ready;}void ERR_PrepareSoldierClip(int,string){preparations++;}void ERR_DeliverSoldierLine(int,string,int){delivered++;}};
|
||||
struct Manager{Array<SCR_PlayerController*>controllers;void GetPlayers(Array<int>&ids){for(int i=0;i<controllers.Count();i++)ids.Insert(i);}auto GetPlayerController(int i){return controllers[i];}};
|
||||
struct Game{Manager players;auto GetPlayerManager(){return &players;}}game;auto GetGame(){return &game;}
|
||||
@@ -55,6 +57,11 @@ struct Fixture{ERR_GameMode mode;Server server;IEntity actor,one,two;SCR_PlayerC
|
||||
bool Speak(){return server.Speak(&actor,&request,&von);}void Prepared(){server.Prepared(request.m_eCommType,"clip");}
|
||||
};
|
||||
int main(){
|
||||
{Fixture f;f.mode.recordings=false;assert(!f.Speak()&&f.mode.voice.requests==0&&f.c1.preparations==0&&f.c1.delivered==0);}
|
||||
{Fixture f;baked=true;f.server.m_Endpoint="baked:speech-v1";f.mode.voice.ready=false;
|
||||
assert(!f.Speak()&&f.mode.voice.requests==0&&f.c1.preparations==1);
|
||||
f.c1.ready=true;f.c2.ready=true;assert(f.Speak()&&f.c1.delivered==1&&f.mode.voice.requests==0);baked=false;
|
||||
}
|
||||
assert(!ERR_SoldierSpeechRules::Near(-1)&&ERR_SoldierSpeechRules::Near(80)&&!ERR_SoldierSpeechRules::Near(80.1f)&&!ERR_SoldierSpeechRules::Near(std::numeric_limits<float>::quiet_NaN()));
|
||||
assert(ERR_SoldierSpeechRules::Text(100).IsEmpty());
|
||||
{Fixture f;assert(!f.Speak()&&f.mode.voice.requests==1);assert(!f.Speak()&&f.mode.voice.requests==1);f.Prepared();assert(!f.Speak()&&f.c1.preparations==1&&f.c2.preparations==1);f.c1.ready=true;assert(!f.Speak()&&!f.c1.delivered&&!f.c2.delivered);f.c2.ready=true;assert(f.Speak()&&f.c1.delivered==1&&f.c2.delivered==1);assert(f.server.Clip(f.request.m_eCommType,"clip")&&!f.server.Clip(f.request.m_eCommType,"other"));}
|
||||
|
||||
@@ -5,7 +5,7 @@ p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_SpeechMenu.c'
|
||||
src = p.read_text()
|
||||
layout = (ROOT / 'addons/core/UI/layouts/ERR/AdminMenu.layout').read_text()
|
||||
menu = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c'
|
||||
for name in ('OpenTTS', 'TTSPage', 'TTSEndpoint', 'TTSToken', 'TTSTest', 'TTSDisable', 'TTSPasteEndpoint', 'TTSPasteToken', 'TTSStatus', 'TTSTokenStatus', 'TTSSelectionState'):
|
||||
for name in ('OpenTTS', 'TTSPage', 'TTSEndpoint', 'TTSToken', 'TTSTest', 'TTSDisable', 'TTSPasteEndpoint', 'TTSPasteToken', 'TTSStatus', 'TTSTokenStatus', 'TTSSelectionState', 'RecordingsOn', 'RecordingsOff', 'RecordingsLabel'):
|
||||
assert f'Name "{name}"' in layout, name
|
||||
assert '"TTSPage"' in body(menu, 'static array<string> Pages(')
|
||||
assert '"OpenTTS"' in body(menu, 'static array<string> Navigation(')
|
||||
@@ -24,9 +24,10 @@ run(r'''
|
||||
#include <string>
|
||||
struct string:std::string {using std::string::string;int Length(){return size();}};
|
||||
float clockNow=1000;
|
||||
struct ERR_SpeechService {int tests=0,disables=0;string endpoint,secret;
|
||||
struct ERR_SpeechService {int tests=0,disables=0,recordingChanges=0;bool recordings=true;string endpoint,secret;
|
||||
void TestAndEnable(void*,string e,string s){tests++;endpoint=e;secret=s;}
|
||||
void Disable(void*){disables++;}
|
||||
void SetRecordings(void*,bool value){recordingChanges++;recordings=value;}
|
||||
};
|
||||
struct Session {bool authorized=true;int replies=0;float m_LastRequest=0;ERR_SpeechService* serviceValue;
|
||||
bool Authorize(){return authorized;}void SpeechReply(string){replies++;}
|
||||
@@ -41,6 +42,9 @@ int main(){
|
||||
session.Speech(1,"","");assert(!service.disables);
|
||||
clockNow+=250;session.Speech(1,"","");assert(service.disables==1);
|
||||
clockNow+=250;session.Speech(8,"","");assert(service.tests==1&&service.disables==1);
|
||||
clockNow+=250;session.authorized=false;session.Speech(3,"","");assert(service.recordings&&!service.recordingChanges);
|
||||
session.authorized=true;session.Speech(3,"","");assert(!service.recordings&&service.recordingChanges==1);
|
||||
clockNow+=250;session.Speech(2,"","");assert(service.recordings&&service.recordingChanges==2);
|
||||
clockNow+=250;session.serviceValue=nullptr;session.Speech(0,"","");assert(session.replies==1);
|
||||
}
|
||||
''')
|
||||
|
||||
@@ -135,7 +135,7 @@ print('PASS: production incremental decoder preserves binary data, bounds each s
|
||||
def client_cpp(text):
|
||||
text = cpp(text)
|
||||
for old, new in [
|
||||
('ERR_WelcomeVoiceRules.', 'Rules::'), ('ERR_GameMode.Get()', 'ERR_GameMode::Get()'),
|
||||
('ResourceName', 'string'), ('ERR_BakedSpeech.', 'ERR_BakedSpeech::'), ('ERR_WelcomeVoiceRules.', 'Rules::'), ('ERR_GameMode.Get()', 'ERR_GameMode::Get()'),
|
||||
('FileIO.', 'FileIO::'), ('AudioSystem.', 'AudioSystem::'),
|
||||
('AudioHandle.Invalid', 'AudioHandle::Invalid'),
|
||||
('FileMode.', 'FileMode::'), ('RestContext context', 'RestContext* context'),
|
||||
@@ -154,7 +154,7 @@ def client_cpp(text):
|
||||
|
||||
client_methods = '\n'.join(client_cpp(body(SOURCE, signature)) for signature in [
|
||||
'protected bool Current()', 'bool PreparingDownload()', 'void Tick(',
|
||||
'protected void Downloaded(', 'protected void CancelDownload()',
|
||||
'protected void BeginPreparation(', 'protected void CancelDownload()',
|
||||
'bool PrepareDownload()', 'void Stop()',
|
||||
]).replace('void Tick(', 'virtual void Tick(').replace('void Stop()', 'virtual void Stop()')
|
||||
soldier_source = ROOT / 'addons/core/Scripts/Game/ERR/10_AI/ERR_SoldierSpeech.c'
|
||||
@@ -175,16 +175,48 @@ connection = 'struct Connection { bool m_Attempted=false;' + '\n'.join(
|
||||
wav = b'RIFF' + (9992).to_bytes(4, 'little') + b'WAVE' + random.Random(71).randbytes(9988)
|
||||
wav64 = json.dumps(base64.b64encode(wav).decode())
|
||||
expected = 'Array<int>{' + ','.join(map(str, wav)) + '}'
|
||||
run(PREAMBLE + decoder + connection + r'''
|
||||
transfer = cpp(body(SOURCE, 'class ERR_VoiceTransfer')).replace('class ERR_VoiceTransfer', 'struct ERR_VoiceTransfer').replace('int m_Total;', 'int m_Total=0;') + ';'
|
||||
run(PREAMBLE + transfer + r'''
|
||||
int main(){
|
||||
for(int size:{60,262140,262144,262148,1600060,2796204}){
|
||||
ERR_VoiceTransfer transfer;string expected(size, 'A');int offset=0,requests=0;
|
||||
while(offset<size){
|
||||
assert(transfer.Part()==offset/262144);
|
||||
int length=std::min(262144,size-offset);
|
||||
int result=transfer.Accept(offset,size,expected.substr(offset,length));
|
||||
offset+=length;requests++;
|
||||
assert(result==(offset==size?1:0));assert(requests<=11);
|
||||
}
|
||||
assert(transfer.Data()==expected);
|
||||
assert(transfer.Accept(offset,size,"AAAA")==-1);
|
||||
transfer.Clear();assert(transfer.Data().IsEmpty()&&transfer.Part()==0);
|
||||
}
|
||||
for(int total:{0,59,61,2796208}){
|
||||
ERR_VoiceTransfer bad;assert(bad.Accept(0,total,"AAAA")==-1);
|
||||
assert(bad.Data().IsEmpty());
|
||||
}
|
||||
ERR_VoiceTransfer partial;assert(partial.Accept(0,524288,string(262144,'A'))==0);
|
||||
assert(partial.Accept(0,524288,string(262144,'A'))==-1); // duplicate
|
||||
assert(partial.Accept(524288,786432,string(262144,'A'))==-1); // gap
|
||||
assert(partial.Accept(262144,524292,string(262144,'A'))==-1); // changed size
|
||||
assert(partial.Accept(262144,524288,string(262140,'A'))==-1); // truncated
|
||||
assert(partial.Accept(262144,524288,string(262148,'A'))==-1); // oversized
|
||||
assert(partial.Data().Length()==262144);
|
||||
assert(partial.Accept(262144,524288,string(262144,'A'))==1);
|
||||
}
|
||||
''')
|
||||
print('PASS: production speech transfer checks order, exact part sizes and stable bounded totals without changing audio')
|
||||
run(PREAMBLE + decoder + transfer + connection + r'''
|
||||
namespace AudioHandle {const int Invalid=-1;}
|
||||
namespace AudioSystem {int stopped=0;void TerminateSound(int){stopped++;}}
|
||||
struct ERR_GameMode {static inline ERR_GameMode* live=nullptr;bool active=true;
|
||||
struct ERR_GameMode {static inline ERR_GameMode* live=nullptr;bool active=true;bool ERR_RecordingsEnabled(){return true;}
|
||||
static ERR_GameMode* Get(){return live;}bool Active(){return active;}};
|
||||
struct RestCallback {string data;void SetOnSuccess(int){}void SetOnError(int){}string GetData(){return data;}};
|
||||
int requests=0;
|
||||
struct RestContext {void SetTimeout(int){}void GET(RestCallback*,string){requests++;}};
|
||||
struct Api {RestContext context;RestContext* GetContext(string){return &context;}};
|
||||
struct Rules {static bool HTTPReady(){return true;}
|
||||
struct ERR_BakedSpeech {static string Graph(string){return "";}};
|
||||
struct Rules {static bool HTTPReady(){return true;}static bool Source(string value){return URL(value);}
|
||||
''' + body(SOURCE, 'static bool URL(') + r'''
|
||||
};
|
||||
namespace FileMode {enum Type {WRITE,READ};}
|
||||
@@ -222,6 +254,8 @@ struct Game {
|
||||
} game;
|
||||
Game* GetGame(){return &game;}
|
||||
struct Client {
|
||||
ERR_VoiceTransfer* m_Transfer=nullptr;string m_DownloadURL,m_BakedGraph;
|
||||
void RequestPart(){requests++;}
|
||||
ERR_GameMode* m_Mode=nullptr;string m_Path,m_Text;Connection connection;Connection* m_Connection=&connection;
|
||||
bool m_Stopped=false,m_Downloaded=false,m_Started=false;float m_Wait=0;
|
||||
RestCallback* m_Callback=nullptr;ERR_WelcomeVoiceDecoder* m_Decoder=nullptr;File* m_Output=nullptr;
|
||||
@@ -229,7 +263,7 @@ struct Client {
|
||||
explicit Client(string path):m_Mode(ERR_GameMode::live),m_Path(path){}
|
||||
bool Permit(){return true;}virtual void Play(){plays++;}
|
||||
''' + client_methods + r'''
|
||||
void Receive(string encoded){Tick(3,true,"http://voice/clip","hello");RestCallback response;response.data=encoded;Downloaded(&response);}
|
||||
void Receive(string encoded){Tick(3,true,"http://voice/clip","hello");BeginPreparation(encoded);}
|
||||
};
|
||||
''' + '\n'.join(queue_methods) + r'''
|
||||
struct Soldier:Client {
|
||||
@@ -276,7 +310,7 @@ int main(){
|
||||
game.ERR_PrepareVoiceDownloads();int beforeCloses=closes;stopped.Stop();
|
||||
assert(closes==beforeCloses+1&&!FileIO::files["stopped"].exists&&!stopped.PreparingDownload());
|
||||
assert(game.m_ERRVoiceDownloads.Count()==1);beforeWrites=writes;
|
||||
stopped.Stop();RestCallback late;late.data=wav;stopped.Downloaded(&late);
|
||||
stopped.Stop();stopped.BeginPreparation(wav);
|
||||
stopped.Tick(1,true,"","");assert(stopped.plays==0&&writes==beforeWrites&&game.m_ERRVoiceDownloads.Count()==1);
|
||||
Drain();assert(survivor.m_Downloaded);
|
||||
// Each failure path removes the partial file and never announces readiness.
|
||||
|
||||
@@ -17,9 +17,9 @@ def adapt(s):
|
||||
s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->')
|
||||
return s
|
||||
rules='struct ERR_SpeechRules {\n'+'\n'.join(adapt(body(p,s)) for s in ['static bool Endpoint(', 'static string Normalize(', 'static bool Token(', 'static bool Text('])+'\n};'
|
||||
methods=['string StaticURL()', 'bool Ready()', 'bool Busy()', 'bool Current()', 'bool IdleFor(',
|
||||
methods=['bool Bundled()', 'string StaticURL()', 'bool Ready()', 'bool Busy()', 'bool Current()', 'bool IdleFor(',
|
||||
'void ERR_SpeechService(', 'void Snapshot(', 'protected bool Save(',
|
||||
'void Disable(', 'void TestAndEnable(', 'protected void Begin(',
|
||||
'void SetRecordings(', 'void Disable(', 'void TestAndEnable(', 'protected void Begin(',
|
||||
'bool Request(', 'void Tick()', 'void Finished(', 'void Stop()']
|
||||
code='\n'.join(adapt(body(p,s)) for s in methods).replace('void ERR_SpeechService(', 'void Init(')
|
||||
run(r'''
|
||||
@@ -44,6 +44,8 @@ struct JsonSaveContext {std::map<string,string> data;void EnableTypeDiscriminato
|
||||
namespace ERR_ProfileConfig {string VOICE="voice";bool Load(string,JsonLoadContext*){return loadOK;}bool Save(string,JsonSaveContext* json){if(!saveOK)return false;saves++;saved=json->data;return true;} }
|
||||
namespace Replication {bool IsServer(){return server;}}
|
||||
struct ERR_GameMode {static ERR_GameMode* live;static ERR_GameMode* Get(){return live;}bool active=true;int restored=0,published=0;bool pending=false;string lastURL,lastText;
|
||||
bool recordings=true;bool ERR_RecordingsEnabled(){return recordings;}void ERR_SetRecordingsEnabled(bool v){recordings=v;}
|
||||
bool bundled=false;bool ERR_UsesBundledWelcome(){return bundled;}
|
||||
bool Active(){return active;}string ERR_CurrentWelcomeText(){return "Welcome to Nogova.";}
|
||||
void ERR_RestoreStaticWelcome(){restored++;pending=false;}
|
||||
void ERR_PublishWelcome(string url,string text){published++;lastURL=url;lastText=text;pending=false;}
|
||||
@@ -59,7 +61,7 @@ struct ERR_SpeechRequest {string Text,Endpoint,Token;bool Enable=false,Boot=fals
|
||||
};
|
||||
struct ERR_SpeechService {
|
||||
ERR_GameMode* m_Mode=nullptr;string m_Endpoint,m_Token,m_StaticURL,m_Status="Not configured";
|
||||
bool m_Enabled=false,m_Ready=false,m_BootAttempted=false,m_Stopped=false;
|
||||
bool m_Bundled=true;bool m_Enabled=false,m_Ready=false,m_BootAttempted=false,m_Stopped=false;
|
||||
ERR_SpeechRequest* m_Request=nullptr;float m_Deadline=0,m_LastTest=-60000,m_LastRequest=-5000;
|
||||
'''+code+r'''
|
||||
};
|
||||
@@ -72,6 +74,7 @@ int main(){
|
||||
ERR_GameMode mode;ERR_GameMode::live=&mode;ERR_AdminSession admin,other;
|
||||
profile={{"welcomeUrl","http://old/welcome.b64"}};
|
||||
ERR_SpeechService service;service.Init(&mode);assert(service.StaticURL()=="http://old/welcome.b64");service.Tick();assert(starts==0&&saves==0&&!service.Ready());
|
||||
mode.bundled=true;int beforeStarts=starts;service.Tick();assert(service.Bundled()&&starts==beforeStarts&&!service.Busy());mode.bundled=false;
|
||||
ERR_SpeechReceiver receiver;assert(!service.Request("Hello",&receiver));
|
||||
string secret="AbCdefghijklmnopqrstuvwxyz0123456789";
|
||||
admin.valid=false;service.TestAndEnable(&admin,"https://speech/",secret);assert(starts==0);admin.valid=true;
|
||||
@@ -86,6 +89,7 @@ int main(){
|
||||
admin.valid=false;service.Finished(first,"https://speech/clip");assert(saves==0&&!service.Ready());admin.valid=true;
|
||||
game.now+=60001;service.TestAndEnable(&admin,"https://speech/",secret);first=service.m_Request;
|
||||
service.Finished(first,"https://speech/clip");assert(saves==1&&service.Ready()&&admin.previews==1&&mode.published==1);
|
||||
assert(!service.Bundled()&&saved["bundledSpeech"]=="false");
|
||||
assert(saved["briefingToken"]==secret&&saved["welcomeUrl"]=="http://old/welcome.b64"&&saved["ttsEnabled"]=="true");
|
||||
Array snapshot;service.Snapshot(&snapshot);for(string line:snapshot)assert(line.find(secret)==string::npos);
|
||||
game.now+=60001;service.TestAndEnable(&admin,"https://other/", "");assert(!service.Busy()&&saves==1);
|
||||
@@ -94,8 +98,9 @@ int main(){
|
||||
game.now+=60001;service.TestAndEnable(&admin,"https://speech/", "");first=service.m_Request;
|
||||
service.Disable(&other);assert(!service.Ready()&&!service.Busy()&&first->cancelled&&saved["ttsEnabled"]=="false");
|
||||
service.Finished(first,"https://speech/late");assert(!service.Ready()&&admin.previews==1); // late success cannot re-enable
|
||||
assert(service.Bundled()&&saved["bundledSpeech"]=="true");
|
||||
profile=saved;ERR_SpeechService disabled;disabled.Init(&mode);disabled.Tick();assert(!disabled.Ready()&&!disabled.Busy());
|
||||
profile["ttsEnabled"]="true";ERR_SpeechService boot;boot.Init(&mode);int previous=starts;http=false;boot.Tick();assert(starts==previous);http=true;
|
||||
profile["ttsEnabled"]="true";profile["bundledSpeech"]="false";ERR_SpeechService boot;boot.Init(&mode);int previous=starts;http=false;boot.Tick();assert(starts==previous);http=true;
|
||||
boot.Tick();assert(boot.Busy()&&mode.pending);first=boot.m_Request;assert(first->Boot);
|
||||
boot.Finished(first,"https://speech/cached");assert(boot.Ready()&&!mode.pending&&saves==2);
|
||||
assert(!boot.IdleFor(65000));game.now+=65000;assert(boot.IdleFor(65000));
|
||||
@@ -106,6 +111,16 @@ int main(){
|
||||
boot.Finished(first,"https://speech/late");assert(receiver.completed==1&&boot.Ready());
|
||||
ERR_SpeechService badBoot;badBoot.Init(&mode);badBoot.Tick();assert(badBoot.Busy());
|
||||
badBoot.Finished(badBoot.m_Request,"");assert(!badBoot.Ready()&&!badBoot.Busy());
|
||||
// A recordings toggle preserves live preferences and credentials, and commits only on successful save.
|
||||
assert(mode.recordings);int beforeSaves=saves;
|
||||
admin.valid=false;boot.SetRecordings(&admin,false);assert(mode.recordings&&saves==beforeSaves);admin.valid=true;
|
||||
saveOK=false;boot.SetRecordings(&admin,false);assert(mode.recordings&&saves==beforeSaves);saveOK=true;
|
||||
boot.SetRecordings(&admin,false);assert(!mode.recordings&&saved["recordingsEnabled"]=="false"&&saved["bundledSpeech"]=="false"&&saved["ttsEnabled"]=="true"&&saved["briefingToken"]==secret);
|
||||
Array muted;boot.Snapshot(&muted);bool found=false;for(string line:muted)if(line=="recordingsEnabled=0")found=true;assert(found);
|
||||
profile=saved;ERR_SpeechService reload;reload.Init(&mode);assert(!mode.recordings);
|
||||
reload.Disable(&admin);assert(!mode.recordings&&saved["recordingsEnabled"]=="false");
|
||||
reload.SetRecordings(&admin,true);assert(mode.recordings&&saved["recordingsEnabled"]=="true"&&saved["bundledSpeech"]=="true");
|
||||
profile.erase("recordingsEnabled");mode.recordings=false;ERR_SpeechService legacy;legacy.Init(&mode);assert(mode.recordings);
|
||||
boot.Stop();assert(!boot.Current()&&!boot.IdleFor(0));
|
||||
}
|
||||
''')
|
||||
|
||||
@@ -24,7 +24,7 @@ struct ERR_GameMode {bool active=true,pending=false;std::string url="clip";Roste
|
||||
bool Active(){return active;}bool ERR_WelcomePending(){return pending;}std::string ERR_WelcomeVoiceURL(){return url;}Roster* Players(){return &roster;}};
|
||||
ERR_GameMode* ERR_GameMode::current;
|
||||
namespace Replication {bool server=true;bool IsServer(){return server;}}
|
||||
struct ERR_WelcomeVoiceRules {static bool URL(std::string s){return !s.empty();}};
|
||||
struct ERR_WelcomeVoiceRules {static bool Source(std::string s){return !s.empty();}};
|
||||
struct Game {Controller* owner=nullptr;Controller* canonical=nullptr;float now=1000;Ledger ledger;Controller* GetPlayerController(){return owner;}
|
||||
Game* GetWorld(){return this;}Game* GetPlayerManager(){return this;}float GetWorldTime(){return now;}Controller* GetPlayerController(int){return canonical;}
|
||||
bool ERR_ClaimWelcomeIdentity(UUID id){return ledger.Claim(id);}} game;
|
||||
|
||||
@@ -4,10 +4,10 @@ p = ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_WelcomeVoice.c'
|
||||
url = body(p, 'static bool URL(')
|
||||
connection = 'class Connection { bool m_Attempted=false; public: ' + '\n'.join(body(p,s) for s in ['void MainMenu()', 'bool Claim()', 'bool Attempted()']) + '};'
|
||||
tick = body(p, 'void Tick(')
|
||||
for old,new in [('m_Connection.', 'm_Connection->'), ('m_Callback.', 'm_Callback->'),
|
||||
for old,new in [('m_Mode.', 'm_Mode->'), ('m_Connection.', 'm_Connection->'), ('m_Callback.', 'm_Callback->'),
|
||||
('context.', 'context->'), ('RestContext context', 'RestContext* context'),
|
||||
('GetGame().GetRestApi().GetContext', 'game.api.GetContext'),
|
||||
('null', 'nullptr'), ('ERR_WelcomeVoiceRules.URL', 'Rules::URL'), ('ERR_WelcomeVoiceRules.HTTPReady', 'Rules::HTTPReady'),
|
||||
('null', 'nullptr'), ('ERR_WelcomeVoiceRules.URL', 'Rules::URL'), ('ERR_WelcomeVoiceRules.HTTPReady', 'Rules::HTTPReady'), ('ERR_WelcomeVoiceRules.Source', 'Rules::Source'), ('ERR_BakedSpeech.Graph', 'ERR_BakedSpeech::Graph'), ('ResourceName', 'string'),
|
||||
('FileIO.DeleteFile', 'FileIO::DeleteFile'), ('LogLevel.WARNING','0')]:
|
||||
tick=tick.replace(old,new)
|
||||
run(r'''
|
||||
@@ -17,8 +17,10 @@ struct string:std::string { using std::string::string; string(std::string s):std
|
||||
bool IsEmpty(){return empty();} int Length(){return size();}
|
||||
int IndexOf(string s){auto p=find(s);return p==npos?-1:int(p);}
|
||||
};
|
||||
struct ERR_BakedSpeech { static string Graph(string value){return value=="baked:welcome"?"bundled.acp":"";} };
|
||||
struct Mode {bool enabled=true;bool ERR_RecordingsEnabled(){return enabled;}} mode;
|
||||
bool httpReady=true;
|
||||
struct Rules { static bool HTTPReady(){return httpReady;} '''+url+r''' };
|
||||
struct Rules { static bool HTTPReady(){return httpReady;} static bool Source(string value){return URL(value)||!ERR_BakedSpeech::Graph(value).IsEmpty();} '''+url+r''' };
|
||||
'''+connection+r'''
|
||||
struct RestCallback { void SetOnSuccess(int){} void SetOnError(int){} };
|
||||
int requests=0;
|
||||
@@ -27,7 +29,11 @@ struct Api { RestContext* GetContext(string){return &context;} };
|
||||
struct Game { Api api; } game;
|
||||
struct FileIO { static void DeleteFile(string){} };
|
||||
void Print(string,int=0){}
|
||||
struct ERR_VoiceTransfer {};
|
||||
struct Client {
|
||||
ERR_VoiceTransfer* m_Transfer=nullptr; string m_DownloadURL,m_BakedGraph;
|
||||
void RequestPart(){requests++;}
|
||||
Mode* m_Mode=&mode;void Stop(){m_Stopped=true;m_Downloaded=false;}
|
||||
Connection* m_Connection; bool m_Stopped=false,m_Downloaded=false,m_Started=false,current=true;
|
||||
float m_Wait=0; string m_Path,m_Text; RestCallback* m_Callback=nullptr; int Downloaded=0,Failed=0,plays=0;
|
||||
Client(Connection* c):m_Connection(c){} ~Client(){delete m_Callback;}
|
||||
@@ -35,6 +41,16 @@ struct Client {
|
||||
'''+tick+r'''
|
||||
};
|
||||
int main(){
|
||||
Connection mutedConnection;Client muted(&mutedConnection);mode.enabled=false;
|
||||
muted.Tick(60,true,"baked:welcome","");assert(!mutedConnection.Attempted()&&!muted.m_Downloaded&&requests==0);
|
||||
mode.enabled=true;muted.Tick(3,true,"baked:welcome","");assert(muted.m_Downloaded);
|
||||
mode.enabled=false;muted.Tick(1,true,"baked:welcome","");assert(muted.m_Stopped&&muted.plays==0);
|
||||
Connection liveConnection;Client live(&liveConnection);live.Tick(3,true,"http://voice/clip","");assert(requests==1);requests=0;
|
||||
mode.enabled=true;
|
||||
Connection local;Client baked(&local);httpReady=false;int before=requests;
|
||||
baked.Tick(3,true,"baked:welcome","Generic welcome");assert(requests==before&&baked.m_Downloaded);
|
||||
baked.Tick(1,false,"baked:welcome","");assert(baked.plays==0);
|
||||
baked.Tick(1,true,"baked:welcome","");assert(baked.plays==1&&requests==before);httpReady=true;
|
||||
Connection connection;
|
||||
Client first(&connection);
|
||||
httpReady=false; first.Tick(60,true,"http://voice/clip", ""); assert(requests==0 && !connection.Attempted()); httpReady=true;
|
||||
|
||||
@@ -87,6 +87,63 @@ class Session:
|
||||
|
||||
|
||||
class WelcomeTests(unittest.TestCase):
|
||||
def test_large_cached_audio_downloads_in_bounded_parts(self):
|
||||
# A 25-second Google-format clip exceeds Enfusion's single-response
|
||||
# ceiling. Existing disk caches must work without another synthesis.
|
||||
output = io.BytesIO()
|
||||
with wave.open(output, 'wb') as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24000)
|
||||
wav.writeframes(random.Random(53).randbytes(25 * 24000 * 2))
|
||||
audio = output.getvalue()
|
||||
encoded = base64.b64encode(audio)
|
||||
self.assertGreater(len(encoded), 1024 * 1024)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
def forbidden(text):
|
||||
self.fail('GET must never generate speech')
|
||||
cache = tool.BriefingCache(directory, 'x'*40, forbidden, 'transport-test')
|
||||
key = 'a'*64
|
||||
tool.save_clip(Path(directory)/(key + '.wav'), audio)
|
||||
server = tool.ThreadingHTTPServer(('127.0.0.1', 0), tool.handler_for(audio, cache))
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
connection = http.client.HTTPConnection(*server.server_address, timeout=3)
|
||||
try:
|
||||
for path in ('/welcome.b64', '/clips/' + key + '.b64'):
|
||||
chunks = []
|
||||
for part, offset in enumerate(range(0, len(encoded), tool.VOICE_CHUNK)):
|
||||
connection.request('GET', path + '?part=' + str(part))
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(int(response.getheader('Content-Length')), len(payload))
|
||||
self.assertLessEqual(len(payload), 262272)
|
||||
data = json.loads(payload)
|
||||
self.assertEqual(data['offset'], offset)
|
||||
self.assertEqual(data['total'], len(encoded))
|
||||
chunks.append(data['data'].encode())
|
||||
self.assertEqual(base64.b64decode(b''.join(chunks)), audio)
|
||||
# Legacy clients still receive the original, unmodified clip.
|
||||
connection.request('GET', path)
|
||||
response = connection.getresponse()
|
||||
self.assertEqual(response.read(), encoded)
|
||||
for path, status in [('/welcome.b64?part=99', 416),
|
||||
('/welcome.b64?part=-1', 400),
|
||||
('/welcome.b64?part=0&part=1', 400),
|
||||
('/welcome.wav?part=0', 400),
|
||||
('/welcome.b64?part=10000000', 400)]:
|
||||
connection.request('GET', path)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
self.assertEqual(response.status, status)
|
||||
self.assertEqual(cache.read(key), audio)
|
||||
finally:
|
||||
connection.close()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join(timeout=3)
|
||||
|
||||
def test_bounded_http_request_framing(self):
|
||||
read = tool.read_request_body
|
||||
self.assertEqual(read({'Content-Length': '2'}, io.BytesIO(b'{}')), b'{}')
|
||||
|
||||
Reference in New Issue
Block a user