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 ();
2026-11-08 17:26:51 +00:00
toast ( "Reading radio aloud failed" , lines , false );
2026-11-06 08:28:04 +00:00
}
}
function say ( item ) {
speech . pending = Voice . enqueue ( speech . pending , item , SPEECH_QUEUE );
pump ();
2026-11-11 23:39:17 +00:00
drawBubbles ();
}
function readSilently ( item , next ) {
Object . assign ( item , { started : performance . now (), duration : Bubbles . reading ( item . words . length )});
speech . current = item ;
speech . timer = window . setTimeout (() => {
Object . assign ( speech , { current : null , timer : 0 });
drawSpeaker ();
next ();
}, item . duration * 1000 );
drawSpeaker ();
}
function endSilent () {
window . clearTimeout ( speech . timer );
Object . assign ( speech , { current : null , timer : 0 , busy : false });
}
function dropSilent () {
speech . pending = speech . pending . filter (( item ) => ! item . silent );
if ( speech . timer ) {
endSilent ();
drawSpeaker ();
pump ();
}
2026-11-06 08:28:04 +00:00
}
async function pump () {
if ( speech . busy || ! speech . pending . length ) {
return ;
}
2026-11-11 23:39:17 +00:00
const [ item , ... rest ] = speech . pending ;
const sound = item . silent ? null : context ();
if ( ! item . silent && ! sound ) {
2026-11-06 08:28:04 +00:00
speech . pending = [];
return ;
}
speech . pending = rest ;
speech . busy = true ;
const next = () => {
speech . busy = false ;
pump ();
};
2026-11-11 23:39:17 +00:00
if ( item . silent ) {
readSilently ( item , next );
return ;
}
speech . loading = item ;
drawBubbles ();
2026-11-06 08:28:04 +00:00
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 ());
2026-11-11 23:39:17 +00:00
if ( speech . loading !== item ) {
next ();
return ;
}
2026-11-06 08:28:04 +00:00
const source = sound . createBufferSource ();
const gain = sound . createGain ();
gain . gain . value = loudness ( item . tone );
source . buffer = buffer ;
source . connect ( gain ). connect ( sound . destination );
2026-11-07 19:59:47 +00:00
source . addEventListener ( "ended" , () => {
Object . assign ( speech , { current : null , source : null });
drawSpeaker ();
next ();
}, { once : true });
2026-11-06 08:28:04 +00:00
duck ( buffer . duration );
2026-11-11 23:39:17 +00:00
Object . assign ( item , { started : performance . now (), duration : buffer . duration });
Object . assign ( speech , { current : item , source , loading : null });
2026-11-07 19:59:47 +00:00
drawSpeaker ();
2026-11-06 08:28:04 +00:00
source . start ();
} catch ( error ) {
2026-11-11 23:39:17 +00:00
const kept = speech . loading === item ;
speech . loading = null ;
2026-11-06 08:28:04 +00:00
speechFailed ([ String ( error . message || error )]);
2026-11-11 23:39:17 +00:00
if ( kept && settings . bubbles && item . words ) {
readSilently ( item , next );
return ;
}
2026-11-06 08:28:04 +00:00
next ();
}
}
2026-11-07 19:59:47 +00:00
function cancelSpeech () {
2026-11-11 23:39:17 +00:00
if ( speech . timer ) {
endSilent ();
}
Object . assign ( speech , { pending : [], current : null , loading : null });
2026-11-07 19:59:47 +00:00
if ( speech . source ) {
speech . source . stop ();
}
2026-11-11 23:39:17 +00:00
clearBubbles ();
drawSpeaker ();
2026-11-07 19:59:47 +00:00
}
2026-11-06 08:28:04 +00:00
function speakRows ( rows ) {
2026-11-11 23:39:17 +00:00
const voiced = settings . speech . on && state . speech . tts ;
if ( ! voiced && ! settings . bubbles ) {
2026-11-06 08:28:04 +00:00
return ;
}
2026-11-11 23:39:17 +00:00
for ( const row of rows . filter (( entry ) => entry . ts >= state . now - SPEECH_FRESH )) {
const audible = voiced && Voice . speakable ( row , settings . speech );
if ( audible || ( settings . bubbles && Bubbles . picked ( row , settings . speech ))) {
const line = row . text . split ( "\n" ). map (( part ) => part . trim ()). find ( Boolean ) || "" ;
say ({ text : row . speech , voice : row . voice , tone : Voice . toneOf ( row , HUMAN ), cls : row . class , who : row . source , line : short ( line ), words : Bubbles . words ( row . text ), silent : ! audible });
}
2026-11-06 08:28:04 +00:00
}
}
2026-11-07 19:59:47 +00:00
function micGap () {
return ! window . isSecureContext || ! navigator . mediaDevices
2026-11-08 17:26:51 +00:00
? "The microphone needs a secure context: open the hub via 127.0.0.1 or localhost"
: ! window . MediaRecorder ? "This browser cannot record" : ! state . speech . stt ? `Voice input is off: ${ state . speech . stt_off } ` : "" ;
2026-11-07 19:59:47 +00:00
}
2026-11-06 08:28:04 +00:00
function micButton ( field ) {
2026-11-08 17:26:51 +00:00
const button = h ( "button" , { type : "button" , class : "btn small ghost icon-only mic" , "aria-pressed" : "false" , ... named ( "Voice input" , "Voice input: hold and speak; the text lands in the field and is not sent" )}, icon ( "bi-mic" ));
2026-11-07 19:59:47 +00:00
const reason = micGap ();
2026-11-06 08:28:04 +00:00
if ( reason ) {
button . disabled = true ;
2026-11-08 17:26:51 +00:00
button . title = `Voice input: ${ reason } ` ;
2026-11-06 08:28:04 +00:00
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 ;
2026-11-08 17:26:51 +00:00
toast ( "Microphone not available" , [ String ( error . message || error )], false );
2026-11-06 08:28:04 +00:00
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 ();
}
}
2026-11-07 19:59:47 +00:00
async function wavOf ( blob , sound ) {
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 ) {
2026-11-08 17:26:51 +00:00
throw new Error ( "too short: speak for at least a quarter second" );
2026-11-07 19:59:47 +00:00
}
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 ();
return Voice . wav ( rendered . getChannelData ( 0 ), rate );
}
2026-11-06 08:28:04 +00:00
async function transcribe ( blob , field , button ) {
const sound = context ();
if ( ! sound || ! blob . size ) {
return ;
}
mic . busy = true ;
button . disabled = true ;
try {
2026-11-07 19:59:47 +00:00
const result = await send ( "/api/stt" , await wavOf ( blob , sound ), "audio/wav" );
2026-11-06 08:28:04 +00:00
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 ) {
2026-11-08 17:26:51 +00:00
toast ( "Voice input failed" , [ String ( error . message || error )], false );
2026-11-06 08:28:04 +00:00
} finally {
mic . busy = false ;
button . disabled = false ;
}
}