- Nuovo VoiceRecorder (QAudioInput, PCM 16 bit mono): analisi del livello
in tempo reale e stop automatico dopo il silenzio (pausa e soglia
configurabili dalle impostazioni); produce WAV per /api/transcribe;
emette failed("no-speech") se non rileva voce (cattura scartata)
- ChatPage: modalità dialogo (menu "Voice mode"): ascolto -> trascrizione
-> invio -> lettura della risposta -> riascolto in ciclo; tocco sul
testo di stato per uscire; limite di 3 errori consecutivi poi esce
- Dettatura manuale: invio automatico dopo la trascrizione (opzione,
default attivo, disattivabile)
- Settings: sezione "Voice dialog" (auto-invio, pausa auto-stop,
sensibilità microfono)
- Il percorso di dettatura manuale (Recorder OGG) resta invariato
421 lines
13 KiB
QML
421 lines
13 KiB
QML
import QtQuick 2.0
|
|
import Sailfish.Silica 1.0
|
|
|
|
// Conversazione con Hermes: bolle, streaming live, composer con dettatura.
|
|
Page {
|
|
id: page
|
|
|
|
property string sessionId
|
|
property string noticeText: ""
|
|
// Modalità dialogo (voce continua): ascolto → trascrizione → invio →
|
|
// lettura della risposta → riascolto (vedi toggleVoiceMode()).
|
|
property bool voiceMode: false
|
|
property string voiceState: "idle" // listening|transcribing|sending|streaming|speaking
|
|
property int voiceErrors: 0
|
|
// Altezza tastiera: alza il composer quando la tastiera e' aperta.
|
|
property real kbHeight: Qt.inputMethod.visible
|
|
? Math.max(0, page.height - Qt.inputMethod.keyboardRectangle.y)
|
|
: 0
|
|
|
|
allowedOrientations: Orientation.Portrait
|
|
|
|
SilicaListView {
|
|
id: list
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: composer.top
|
|
model: chat
|
|
spacing: Theme.paddingSmall
|
|
clip: true
|
|
|
|
header: Item {
|
|
width: list.width
|
|
height: headerCol.height
|
|
|
|
Column {
|
|
id: headerCol
|
|
width: parent.width
|
|
|
|
PageHeader {
|
|
title: chat.title.length > 0 ? chat.title : qsTr("Chat")
|
|
}
|
|
|
|
Label {
|
|
x: Theme.horizontalPageMargin
|
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
|
font.pixelSize: Theme.fontSizeExtraSmall
|
|
color: Theme.secondaryHighlightColor
|
|
visible: chat.busy || player.playing
|
|
text: chat.busy ? qsTr("Hermes is writing…")
|
|
: qsTr("Speaking…")
|
|
}
|
|
}
|
|
}
|
|
|
|
PullDownMenu {
|
|
MenuItem {
|
|
text: page.voiceMode ? qsTr("Voice mode: on") : qsTr("Voice mode: off")
|
|
onClicked: page.toggleVoiceMode()
|
|
}
|
|
MenuItem {
|
|
text: qsTr("Read last reply aloud")
|
|
onClicked: page.readLast()
|
|
}
|
|
MenuItem {
|
|
text: appSettings.ttsAutoRead ? qsTr("Auto-read: on") : qsTr("Auto-read: off")
|
|
onClicked: page.toggleAutoRead()
|
|
}
|
|
MenuItem {
|
|
text: qsTr("Stop playback")
|
|
visible: player.playing
|
|
onClicked: page.stopPlayback()
|
|
}
|
|
MenuItem {
|
|
text: qsTr("Cancel run")
|
|
visible: chat.busy
|
|
onClicked: page.cancelRun()
|
|
}
|
|
MenuItem {
|
|
text: qsTr("Resync")
|
|
onClicked: page.resync()
|
|
}
|
|
}
|
|
|
|
delegate: Item {
|
|
id: row
|
|
width: list.width
|
|
height: bubble.height + Theme.paddingMedium
|
|
|
|
Rectangle {
|
|
id: bubble
|
|
property real maxW: list.width - 2 * Theme.horizontalPageMargin
|
|
width: Math.min(lbl.implicitWidth + 2 * Theme.paddingMedium, maxW)
|
|
height: lbl.height + 2 * Theme.paddingSmall
|
|
radius: Theme.paddingSmall
|
|
x: model.role === "user"
|
|
? list.width - width - Theme.horizontalPageMargin
|
|
: Theme.horizontalPageMargin
|
|
color: model.role === "user"
|
|
? Theme.rgba(Theme.highlightBackgroundColor, 0.35)
|
|
: Theme.rgba(Theme.secondaryHighlightColor, 0.18)
|
|
|
|
Label {
|
|
id: lbl
|
|
x: Theme.paddingMedium
|
|
y: Theme.paddingSmall
|
|
width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium)
|
|
wrapMode: Text.Wrap
|
|
color: Theme.primaryColor
|
|
text: model.content + (model.streaming ? " ▍" : "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: composer
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: page.kbHeight
|
|
height: compCol.height + Theme.paddingMedium
|
|
color: Theme.rgba(Theme.highlightBackgroundColor, 0.15)
|
|
|
|
Column {
|
|
id: compCol
|
|
width: parent.width
|
|
spacing: Theme.paddingSmall / 2
|
|
|
|
Label {
|
|
id: statusLbl
|
|
x: Theme.horizontalPageMargin
|
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
|
font.pixelSize: Theme.fontSizeExtraSmall
|
|
color: page.voiceMode ? Theme.highlightColor
|
|
: (recorder.recording ? Theme.highlightColor
|
|
: Theme.secondaryHighlightColor)
|
|
visible: page.voiceMode || recorder.recording || api.transcribing || page.noticeText.length > 0
|
|
truncationMode: TruncationMode.Fade
|
|
text: page.voiceMode
|
|
? page.voiceStatusText()
|
|
: (recorder.recording
|
|
? qsTr("Recording… %1 — tap Stop to transcribe").arg(page.formatMs(recorder.durationMs))
|
|
: (api.transcribing ? qsTr("Transcribing…") : page.noticeText))
|
|
|
|
// In modalità dialogo: un tocco sul testo esce (MouseArea
|
|
// interna al Label: nessuna interferenza con la Column).
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: page.voiceMode
|
|
onClicked: page.exitVoiceMode()
|
|
}
|
|
}
|
|
|
|
Row {
|
|
x: Theme.horizontalPageMargin
|
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
|
spacing: Theme.paddingSmall
|
|
|
|
Button {
|
|
id: micBtn
|
|
text: recorder.recording ? qsTr("Stop") : qsTr("Mic")
|
|
// Button non ha "highlighted": lo stato è nel testo (Mic/Stop)
|
|
enabled: !page.voiceMode && (!api.transcribing && !chat.busy || recorder.recording)
|
|
onClicked: {
|
|
if (recorder.recording)
|
|
recorder.stop()
|
|
else {
|
|
player.stop()
|
|
recorder.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
TextField {
|
|
id: input
|
|
width: parent.width - micBtn.width - sendBtn.width - 2 * parent.spacing
|
|
placeholderText: qsTr("Message…")
|
|
// Invio con EnterKey (vedi LoginPage)
|
|
EnterKey.enabled: text.length > 0
|
|
EnterKey.onClicked: page.doSend()
|
|
}
|
|
|
|
Button {
|
|
id: sendBtn
|
|
text: qsTr("Send")
|
|
enabled: input.text.length > 0 && !chat.busy
|
|
onClicked: page.doSend()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: scrollDebounce
|
|
interval: 150
|
|
onTriggered: list.positionViewAtEnd()
|
|
}
|
|
|
|
Timer {
|
|
id: noticeClear
|
|
interval: 5000
|
|
onTriggered: page.noticeText = ""
|
|
}
|
|
|
|
Timer {
|
|
id: voiceRetry
|
|
interval: 1200
|
|
onTriggered: {
|
|
if (page.voiceMode)
|
|
page.startListening()
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: chat
|
|
onRowCountChanged: scrollDebounce.restart()
|
|
onStreamTick: scrollDebounce.restart()
|
|
onAssistantFinished: {
|
|
if (page.voiceMode) {
|
|
var t = chat.lastAssistantText()
|
|
if (t.length > 0) {
|
|
page.voiceState = "speaking"
|
|
api.speak(t)
|
|
} else {
|
|
voiceRetry.restart()
|
|
}
|
|
return
|
|
}
|
|
if (appSettings.ttsAutoRead)
|
|
page.readLast()
|
|
}
|
|
onNotice: page.showNotice(text)
|
|
}
|
|
|
|
Connections {
|
|
target: api
|
|
onTranscriptionReady: {
|
|
page.voiceErrors = 0
|
|
if (page.voiceMode) {
|
|
if (text.trim().length === 0) {
|
|
voiceRetry.restart()
|
|
return
|
|
}
|
|
input.text = text
|
|
page.voiceState = "sending"
|
|
page.doSend()
|
|
return
|
|
}
|
|
input.text = text
|
|
input.focus = true
|
|
if (appSettings.dictationAutoSend)
|
|
page.doSend()
|
|
}
|
|
onTranscriptionFailed: {
|
|
page.showNotice(error)
|
|
if (page.voiceMode) {
|
|
page.voiceErrors += 1
|
|
if (page.voiceErrors >= 3) {
|
|
page.exitVoiceMode()
|
|
} else {
|
|
voiceRetry.restart()
|
|
}
|
|
}
|
|
}
|
|
onTtsFailed: {
|
|
if (page.voiceMode)
|
|
voiceRetry.restart()
|
|
else
|
|
page.showNotice(error)
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
if (sessionId.length > 0)
|
|
chat.openSession(sessionId)
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
if (page.voiceMode) {
|
|
page.voiceMode = false
|
|
voiceRec.stop()
|
|
player.stop()
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: player
|
|
onFinished: {
|
|
if (page.voiceMode)
|
|
page.startListening()
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: voiceRec
|
|
onListening: {
|
|
if (page.voiceMode)
|
|
page.voiceState = "listening"
|
|
}
|
|
onFinished: {
|
|
if (!page.voiceMode)
|
|
return
|
|
if (wavPath.length === 0)
|
|
return
|
|
page.voiceState = "transcribing"
|
|
api.transcribeFile(wavPath)
|
|
}
|
|
onFailed: {
|
|
if (error === "no-speech") {
|
|
if (page.voiceMode)
|
|
voiceRetry.restart()
|
|
return
|
|
}
|
|
page.showNotice(error)
|
|
if (page.voiceMode) {
|
|
page.voiceErrors += 1
|
|
if (page.voiceErrors >= 3) {
|
|
page.exitVoiceMode()
|
|
page.showNotice(qsTr("Voice mode off after repeated errors"))
|
|
} else {
|
|
voiceRetry.restart()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleVoiceMode() {
|
|
if (page.voiceMode)
|
|
page.exitVoiceMode()
|
|
else
|
|
page.enterVoiceMode()
|
|
}
|
|
|
|
function enterVoiceMode() {
|
|
if (chat.busy || recorder.recording) {
|
|
page.showNotice(qsTr("Wait for the current reply first"))
|
|
return
|
|
}
|
|
player.stop()
|
|
page.voiceErrors = 0
|
|
page.voiceMode = true
|
|
page.voiceState = "listening"
|
|
voiceRec.start()
|
|
}
|
|
|
|
function exitVoiceMode() {
|
|
page.voiceMode = false
|
|
page.voiceState = "idle"
|
|
voiceRec.stop()
|
|
player.stop()
|
|
}
|
|
|
|
function startListening() {
|
|
if (!page.voiceMode)
|
|
return
|
|
page.voiceState = "listening"
|
|
voiceRec.start()
|
|
}
|
|
|
|
function voiceStatusText() {
|
|
if (page.voiceState === "listening")
|
|
return qsTr("● Listening — speak, I stop at the pause (tap to exit)")
|
|
if (page.voiceState === "transcribing")
|
|
return qsTr("Transcribing…")
|
|
if (page.voiceState === "sending")
|
|
return qsTr("Sending…")
|
|
if (page.voiceState === "streaming")
|
|
return qsTr("Hermes is writing…")
|
|
if (page.voiceState === "speaking")
|
|
return qsTr("Speaking…")
|
|
return qsTr("Voice mode on")
|
|
}
|
|
|
|
function doSend() {
|
|
var t = input.text
|
|
if (t.trim().length === 0)
|
|
return
|
|
input.text = ""
|
|
chat.send(t)
|
|
scrollDebounce.restart()
|
|
}
|
|
|
|
function readLast() {
|
|
var t = chat.lastAssistantText()
|
|
if (t.length === 0) {
|
|
page.showNotice(qsTr("No reply to read yet"))
|
|
return
|
|
}
|
|
api.speak(t)
|
|
}
|
|
|
|
function toggleAutoRead() {
|
|
appSettings.ttsAutoRead = !appSettings.ttsAutoRead
|
|
}
|
|
|
|
function stopPlayback() {
|
|
player.stop()
|
|
}
|
|
|
|
function cancelRun() {
|
|
chat.cancel()
|
|
}
|
|
|
|
function resync() {
|
|
chat.resync()
|
|
}
|
|
|
|
function showNotice(text) {
|
|
page.noticeText = text
|
|
noticeClear.restart()
|
|
}
|
|
|
|
function formatMs(ms) {
|
|
var s = Math.floor(ms / 1000)
|
|
var m = Math.floor(s / 60)
|
|
s = s % 60
|
|
return m + ":" + (s < 10 ? "0" + s : s)
|
|
}
|
|
}
|