Bundle mission speech with an admin mute option and fix live audio downloads

This commit is contained in:
2026-10-15 16:53:39 +02:00
parent 07c4d70de5
commit 009b2fb591
181 changed files with 19248 additions and 103 deletions
+41
View File
@@ -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')
+8 -1
View File
@@ -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"));}
+6 -2
View File
@@ -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);
}
''')
+41 -7
View File
@@ -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.
+19 -4
View File
@@ -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));
}
''')
+1 -1
View File
@@ -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;
+19 -3
View File
@@ -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;
+57
View File
@@ -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'{}')