Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,11 @@ <h2 v-if="connectionRefused">
<div class="nico-nico-messages-container"
v-if="streamSlot.isNicoNicoMode">
</div>
<div
v-if="streamSlot.withSound && getStreamSubtitleText(index)"
class="stream-subtitles-overlay">
{{ getStreamSubtitleText(index) }}
</div>
</div>
<div :id="'vu-meter-container-' + index"
v-show="streamSlot.isActive
Expand Down Expand Up @@ -308,6 +313,11 @@ <h2 v-if="connectionRefused">
@value-changed="gain => onGainChanged(index, gain)"></numeric-value-control>
</div>
</div>
<div
v-if="!streamSlot.withVideo && getStreamSubtitleText(index)"
class="audio-stream-subtitles">
{{ getStreamSubtitleText(index) }}
</div>
</div>
<div v-if="streamSlotIdInWhichIWantToStream == index">

Expand Down Expand Up @@ -786,6 +796,11 @@ <h2 v-if="connectionRefused">
v-model="isStreamInboundVuMeterEnabled" v-on:change="storeSet('isStreamInboundVuMeterEnabled')"><label
for="preferences-streams-inbound-vu-meter-enabled">{{ $t("ui.preferences_streams_inbound_vu_meter_enabled") }}</label>
</div>
<div class='popup-item'>
<input type="checkbox" id="preferences-streams-auto-subtitles-enabled"
v-model="isStreamAutoSubtitlesEnabled" v-on:change="handleStreamAutoSubtitlesEnabled"><label
for="preferences-streams-auto-subtitles-enabled">{{ $t("ui.preferences_streams_auto_subtitles_enabled") }}</label>
</div>
</div>
<div class='popup-section'>
<div class='popup-header'>{{ $t("ui.preferences_title_toolbar") }}</div>
Expand Down
217 changes: 217 additions & 0 deletions src/frontend/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
getFormattedCurrentDate,
requestNotificationPermission,
getDeviceList,
getSpeechRecognitionConstructor,
getClickCoordinatesWithinCanvas,
htmlToControlChars,
controlCharsToHtml,
Expand Down Expand Up @@ -330,6 +331,7 @@ const vueApp = createApp(defineComponent({
isCoinSoundEnabled: localStorage.getItem("isCoinSoundEnabled") != "false",
isStreamAutoResumeEnabled: localStorage.getItem("isStreamAutoResumeEnabled") != "false",
isStreamInboundVuMeterEnabled: localStorage.getItem("isStreamInboundVuMeterEnabled") != "false",
isStreamAutoSubtitlesEnabled: localStorage.getItem("isStreamAutoSubtitlesEnabled") == "true",
showLogAboveToolbar: localStorage.getItem("showLogAboveToolbar") == "true",
showLogDividers: localStorage.getItem("showLogDividers") == "true",
isIgnoreOnBlock: localStorage.getItem("isIgnoreOnBlock") == "true",
Expand Down Expand Up @@ -412,6 +414,10 @@ const vueApp = createApp(defineComponent({
// the key is the slot ID
inboundAudioProcessors: {} as {[slotId: number]: AudioProcessor},
outboundAudioProcessor: null as AudioProcessor | null,
subtitleRecognizers: {} as {[slotId: number]: any},
streamSubtitleRestartTimers: {} as {[slotId: number]: number | null},
streamSubtitles: {} as {[slotId: number]: { finalText: string, interimText: string, clearTimer: number | null }},
hasShownSubtitleSupportWarning: false,
}
},
provide()
Expand Down Expand Up @@ -3054,6 +3060,11 @@ const vueApp = createApp(defineComponent({
const audioTrack = this.outboundAudioProcessor.destination.stream.getAudioTracks()[0]

this.mediaStream.addTrack(audioTrack)

if (this.streamSlotIdInWhichIWantToStream !== null)
this.startStreamSubtitleRecognition(
this.streamSlotIdInWhichIWantToStream,
this.outboundAudioProcessor.getProcessedAudioTrack())
}
}

Expand Down Expand Up @@ -3189,6 +3200,7 @@ const vueApp = createApp(defineComponent({

this.streamSlotIdInWhichIWantToStream = null;

this.stopStreamSubtitleRecognition(streamSlotId)
this.resetRtcPeerSlot(streamSlotId)

// On small screens, displaying the <video> element seems to cause a reflow in a way that
Expand Down Expand Up @@ -3269,6 +3281,9 @@ const vueApp = createApp(defineComponent({
setTimeout(() => {this.streams[streamSlotId].isJumping = false}, 100)
})
await this.inboundAudioProcessors[streamSlotId].initialize()
this.startStreamSubtitleRecognition(
streamSlotId,
this.inboundAudioProcessors[streamSlotId].getProcessedAudioTrack())
}
}
catch (exc)
Expand Down Expand Up @@ -3299,6 +3314,8 @@ const vueApp = createApp(defineComponent({
await this.inboundAudioProcessors[streamSlotId].dispose()
delete this.inboundAudioProcessors[streamSlotId]
}

this.stopStreamSubtitleRecognition(streamSlotId)
},
async wantToDropStream(streamSlotId: number)
{
Expand Down Expand Up @@ -3451,6 +3468,200 @@ const vueApp = createApp(defineComponent({
this.wantToStream = false;
this.streamSlotIdInWhichIWantToStream = null;
},
getStreamSubtitleState(streamSlotId: number)
{
if (!this.streamSubtitles[streamSlotId])
{
this.streamSubtitles[streamSlotId] = {
finalText: '',
interimText: '',
clearTimer: null,
}
}
return this.streamSubtitles[streamSlotId]
},
getStreamSubtitleText(streamSlotId: number)
{
const subtitleState = this.streamSubtitles[streamSlotId]
if (!subtitleState) return ''
return [subtitleState.finalText, subtitleState.interimText].filter(Boolean).join(' ')
},
clearStreamSubtitle(streamSlotId: number)
{
const subtitleState = this.getStreamSubtitleState(streamSlotId)
if (subtitleState.clearTimer)
clearTimeout(subtitleState.clearTimer)
subtitleState.finalText = ''
subtitleState.interimText = ''
subtitleState.clearTimer = null
},
scheduleStreamSubtitleClear(streamSlotId: number)
{
const subtitleState = this.getStreamSubtitleState(streamSlotId)
if (subtitleState.clearTimer)
clearTimeout(subtitleState.clearTimer)
subtitleState.clearTimer = window.setTimeout(() => {
const latestSubtitleState = this.streamSubtitles[streamSlotId]
if (!latestSubtitleState) return
latestSubtitleState.finalText = ''
latestSubtitleState.interimText = ''
latestSubtitleState.clearTimer = null
}, 7000)
},
stopStreamSubtitleRecognition(streamSlotId: number)
{
const restartTimer = this.streamSubtitleRestartTimers[streamSlotId]
if (restartTimer)
{
clearTimeout(restartTimer)
this.streamSubtitleRestartTimers[streamSlotId] = null
}

const recognition = this.subtitleRecognizers[streamSlotId]
if (recognition)
{
;(recognition as any).__gikopoiStopped = true
try
{
recognition.stop()
}
catch (exc)
{
console.debug(exc)
}
delete this.subtitleRecognizers[streamSlotId]
}

this.clearStreamSubtitle(streamSlotId)
},
startStreamSubtitleRecognition(streamSlotId: number, audioTrack: MediaStreamTrack | null)
{
this.stopStreamSubtitleRecognition(streamSlotId)

if (!this.isStreamAutoSubtitlesEnabled || !audioTrack || audioTrack.readyState != "live")
return

const SpeechRecognition = getSpeechRecognitionConstructor()
if (!SpeechRecognition)
{
if (!this.hasShownSubtitleSupportWarning)
{
this.hasShownSubtitleSupportWarning = true
this.showWarningToast(this.$t("msg.auto_subtitles_not_supported"))
}
return
}

const subtitleState = this.getStreamSubtitleState(streamSlotId)
const recognition = new SpeechRecognition()
this.subtitleRecognizers[streamSlotId] = recognition

recognition.continuous = true
recognition.interimResults = true
recognition.lang = this.language || navigator.language || "en-US"
recognition.maxAlternatives = 1

recognition.onresult = (event: any) => {
if (this.subtitleRecognizers[streamSlotId] !== recognition)
return

let interimText = ''
let latestFinalText = subtitleState.finalText

for (let i = event.resultIndex || 0; i < event.results.length; i++)
{
const transcript = event.results[i][0]?.transcript?.trim()
if (!transcript)
continue

if (event.results[i].isFinal)
latestFinalText = transcript
else
interimText += (interimText ? ' ' : '') + transcript
}

subtitleState.finalText = latestFinalText
subtitleState.interimText = interimText

if (latestFinalText)
this.scheduleStreamSubtitleClear(streamSlotId)
}

recognition.onerror = (event: any) => {
if (this.subtitleRecognizers[streamSlotId] !== recognition)
return

if ((event?.error == 'not-allowed' || event?.error == 'service-not-allowed')
&& !this.hasShownSubtitleSupportWarning)
{
this.hasShownSubtitleSupportWarning = true
this.showWarningToast(this.$t("msg.auto_subtitles_not_supported"))
}
}

recognition.onend = () => {
if (this.subtitleRecognizers[streamSlotId] !== recognition || (recognition as any).__gikopoiStopped)
return

if (!this.isStreamAutoSubtitlesEnabled || audioTrack.readyState != "live")
return

this.streamSubtitleRestartTimers[streamSlotId] = window.setTimeout(() => {
if (this.subtitleRecognizers[streamSlotId] !== recognition)
return

try
{
recognition.start(audioTrack)
}
catch (exc)
{
console.debug("Auto subtitle restart failed", exc)
}
}, 250)
}

try
{
recognition.start(audioTrack)
}
catch (exc)
{
console.error(exc)
delete this.subtitleRecognizers[streamSlotId]
if (!this.hasShownSubtitleSupportWarning)
{
this.hasShownSubtitleSupportWarning = true
this.showWarningToast(this.$t("msg.auto_subtitles_not_supported"))
}
}
},
refreshStreamSubtitleRecognitions()
{
const activeSlotIds = new Set<number>()

Object.keys(this.streamSubtitles).forEach(slotId => {
this.stopStreamSubtitleRecognition(parseInt(slotId))
})

if (!this.isStreamAutoSubtitlesEnabled)
return

if (this.streamSlotIdInWhichIWantToStream !== null && this.outboundAudioProcessor)
{
activeSlotIds.add(this.streamSlotIdInWhichIWantToStream)
this.startStreamSubtitleRecognition(
this.streamSlotIdInWhichIWantToStream,
this.outboundAudioProcessor.getProcessedAudioTrack())
}

Object.keys(this.inboundAudioProcessors).forEach(slotIdString => {
const slotId = parseInt(slotIdString)
if (activeSlotIds.has(slotId))
return
this.startStreamSubtitleRecognition(slotId, this.inboundAudioProcessors[slotId].getProcessedAudioTrack())
})
},
changeStreamVolume(streamSlotId: number)
{
const volumeSlider = document.getElementById("volume-" + streamSlotId) as HTMLInputElement
Expand Down Expand Up @@ -3534,6 +3745,7 @@ const vueApp = createApp(defineComponent({
{
this.storeSet('language');
this.setLanguage();
this.refreshStreamSubtitleRecognitions()
},
storeSet(itemName: string, value?: any)
{
Expand Down Expand Up @@ -3693,6 +3905,11 @@ const vueApp = createApp(defineComponent({
this.storeSet('customMentionSoundPattern')
this.setMentionRegexObjects()
},
handleStreamAutoSubtitlesEnabled()
{
this.storeSet('isStreamAutoSubtitlesEnabled')
this.refreshStreamSubtitleRecognitions()
},
handleEnableTextToSpeech()
{
if (window.speechSynthesis)
Expand Down
29 changes: 29 additions & 0 deletions src/frontend/style/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,35 @@ button.checked {
transition: opacity 0.3s;
}

.stream-subtitles-overlay
{
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.72);
color: white;
text-align: center;
text-shadow: 0 1px 2px black;
font-size: 14px;
line-height: 1.35;
word-break: break-word;
pointer-events: none;
}

.audio-stream-subtitles
{
margin-top: 6px;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.72);
color: white;
font-size: 12px;
line-height: 1.35;
word-break: break-word;
}

.video-container:hover > .pin-video-button
{
/* visibility: visible; */
Expand Down
Loading