2026-11-06 08:28:04 +00:00
function loudness ( kind ) {
return Voice . loudness ( kind , settings );
}
function speechFailed ( lines ) {
if ( Date . now () - speech . warned > SPEECH_QUIET ) {
speech . warned = Date . now ();
toast ( "Funk vorlesen fehlgeschlagen" , lines , false );
}
}
function say ( item ) {
speech . pending = Voice . enqueue ( speech . pending , item , SPEECH_QUEUE );
pump ();
}
async function pump () {
if ( speech . busy || ! speech . pending . length ) {
return ;
}
const sound = context ();
if ( ! sound ) {
speech . pending = [];
return ;
}
const [ item , ... rest ] = speech . pending ;
speech . pending = rest ;
speech . busy = true ;
const next = () => {
speech . busy = false ;
pump ();
};
try {
2026-11-06 22:20:06 +00:00
const response = await fetch ( "/api/tts" , { method : "POST" , headers : { "X-Kvb-Orchestrator" : "1" , "Content-Type" : "application/json" }, body : JSON . stringify ({ text : item . text , voice : item . voice }), cache : "no-store" });
2026-11-06 08:28:04 +00:00
if ( ! response . ok ) {
throw new Error ( ` ${ response . status } ${ ( await response . text ()). trim () } ` );
}
const buffer = await sound . decodeAudioData ( await response . arrayBuffer ());
const source = sound . createBufferSource ();
const gain = sound . createGain ();
gain . gain . value = loudness ( item . tone );
source . buffer = buffer ;
source . connect ( gain ). connect ( sound . destination );
source . addEventListener ( "ended" , next , { once : true });
duck ( buffer . duration );
source . start ();
} catch ( error ) {
speechFailed ([ String ( error . message || error )]);
next ();
}
}
function speakRows ( rows ) {
if ( ! settings . speech . on || ! state . speech . tts ) {
return ;
}
for ( const row of rows . filter (( entry ) => entry . ts >= state . now - SPEECH_FRESH && Voice . speakable ( entry , settings . speech ))) {
say ({ text : row . speech , voice : row . voice , tone : Voice . toneOf ( row , HUMAN ), cls : row . class });
}
}
function micButton ( field ) {
const button = h ( "button" , { type : "button" , class : "btn small ghost icon-btn mic" , "aria-pressed" : "false" , "aria-label" : "Spracheingabe: gedrückt halten und sprechen" , title : "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet" }, "🎙" );
const reason = ! window . isSecureContext || ! navigator . mediaDevices
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
2026-11-06 18:01:36 +00:00
: ! window . MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : ! state . speech . stt ? `Spracheingabe ist aus: ${ state . speech . stt_off } ` : "" ;
2026-11-06 08:28:04 +00:00
if ( reason ) {
button . disabled = true ;
button . title = reason ;
return button ;
}
const press = ( event ) => {
event . preventDefault ();
record ( field , button );
};
button . addEventListener ( "pointerdown" , press );
for ( const name of [ "pointerup" , "pointerleave" , "pointercancel" ]) {
button . addEventListener ( name , release );
}
button . addEventListener ( "keydown" , ( event ) => {
if (( event . key === " " || event . key === "Enter" ) && ! event . repeat ) {
press ( event );
}
});
button . addEventListener ( "keyup" , ( event ) => {
if ( event . key === " " || event . key === "Enter" ) {
event . preventDefault ();
release ();
}
});
return button ;
}
async function record ( field , button ) {
if ( mic . held || mic . recorder || mic . busy ) {
return ;
}
mic . held = true ;
let media = null ;
try {
media = await navigator . mediaDevices . getUserMedia ({ audio : { channelCount : 1 , echoCancellation : true , noiseSuppression : true }});
} catch ( error ) {
mic . held = false ;
toast ( "Mikrofon nicht verfügbar" , [ String ( error . message || error )], false );
return ;
}
if ( ! mic . held ) {
media . getTracks (). forEach (( track ) => track . stop ());
return ;
}
const recorder = new MediaRecorder ( media );
const chunks = [];
recorder . addEventListener ( "dataavailable" , ( event ) => {
if ( event . data . size ) {
chunks . push ( event . data );
}
});
recorder . addEventListener ( "stop" , () => {
media . getTracks (). forEach (( track ) => track . stop ());
mic . recorder = null ;
button . setAttribute ( "aria-pressed" , "false" );
transcribe ( new Blob ( chunks , { type : recorder . mimeType }), field , button );
}, { once : true });
mic . recorder = recorder ;
recorder . start ();
button . setAttribute ( "aria-pressed" , "true" );
mic . timer = window . setTimeout ( release , state . speech . seconds * 1000 );
}
function release () {
mic . held = false ;
window . clearTimeout ( mic . timer );
if ( mic . recorder && mic . recorder . state === "recording" ) {
mic . recorder . stop ();
}
}
async function transcribe ( blob , field , button ) {
const sound = context ();
if ( ! sound || ! blob . size ) {
return ;
}
mic . busy = true ;
button . disabled = true ;
try {
const decoded = await sound . decodeAudioData ( await blob . arrayBuffer ());
const rate = state . speech . rate ;
const length = Voice . frames ( decoded . duration , rate , state . speech . seconds );
if ( length < rate / 4 ) {
throw new Error ( "zu kurz: halte die Taste, solange du sprichst" );
}
const offline = new OfflineAudioContext ( 1 , length , rate );
const source = offline . createBufferSource ();
source . buffer = decoded ;
source . connect ( offline . destination );
source . start ();
const rendered = await offline . startRendering ();
const result = await send ( "/api/stt" , Voice . wav ( rendered . getChannelData ( 0 ), rate ), "audio/wav" );
if ( ! result . ok || ! result . data ) {
throw new Error ( ` ${ result . status } ${ result . text } ` . trim ());
}
if ( result . data . text ) {
field . value = [ field . value . trim (), result . data . text ]. filter ( Boolean ). join ( " " );
field . dispatchEvent ( new Event ( "input" , { bubbles : true }));
}
field . focus ();
} catch ( error ) {
toast ( "Spracheingabe fehlgeschlagen" , [ String ( error . message || error )], false );
} finally {
mic . busy = false ;
button . disabled = false ;
}
}