aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-18 20:28:07 +0200
committerDanilo M. <danix@danix.xyz>2026-09-18 20:28:07 +0200
commita830263c8fa51922ecc44a5eab7ce21d29a1ba6a (patch)
tree3cd4853f92068f48ead25483e93b5d68732743a7
parent122e50fb67540e24cc49b880fd78ba26ba043281 (diff)
downloadllamachat-a830263c8fa51922ecc44a5eab7ce21d29a1ba6a.tar.gz
llamachat-a830263c8fa51922ecc44a5eab7ce21d29a1ba6a.zip
feat: record control and voice-note history marker
-rw-r--r--llamachat/ui.py80
-rwxr-xr-xtest_llamachat.py24
2 files changed, 103 insertions, 1 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 9a7aa0d..101f1a8 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -36,6 +36,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QWidget,
)
+from . import audio
from . import backend, models as models_mod, prompts, skills
from . import providers as providers_mod
from .backend import Attachment, BackendError, SearchConfig
@@ -96,6 +97,14 @@ def _button_icon(kind: str) -> QIcon:
path.lineTo(9.5, 19.5)
path.closeSubpath()
painter.drawPath(path)
+ elif kind == "record":
+ # A microphone: capsule, cradle arc and stand.
+ capsule = QPainterPath()
+ capsule.addRoundedRect(QRectF(9.5, 4.0, 5.0, 9.5), 2.5, 2.5)
+ painter.drawPath(capsule)
+ painter.drawArc(QRectF(7.0, 7.0, 10.0, 9.5), 200 * 16, 140 * 16)
+ painter.drawLine(12, 17, 12, 20)
+ painter.drawLine(9, 20, 15, 20)
elif kind == "stop":
painter.setBrush(colour)
painter.drawRoundedRect(QRectF(6.5, 6.5, 11, 11), 2, 2)
@@ -522,6 +531,11 @@ class ChatWindow(QMainWindow):
# Router-reported input modalities per model id, refreshed on listing.
self.modalities: dict[str, frozenset[str]] = {}
+ self.recording = False
+ self.recorder = audio.AudioRecorder(self) if audio.AVAILABLE else None
+ if self.recorder is not None:
+ self.recorder.capped.connect(self._finish_recording)
+
# Which system prompt this conversation uses: a name from the store,
# the NONE sentinel, or CUSTOM with text held in prompt_custom.
self.prompt_name = cfg.default_prompt
@@ -754,6 +768,13 @@ class ChatWindow(QMainWindow):
self.new_button.clicked.connect(self.new_session)
buttons.addWidget(self.new_button)
+ self.record_button = QPushButton("Record")
+ self.record_button.setIcon(_button_icon("record"))
+ self.record_button.setToolTip("Record audio for an audio-capable model")
+ self.record_button.clicked.connect(self._toggle_record)
+ self.record_button.hide()
+ buttons.addWidget(self.record_button)
+
self.attach_button = QPushButton("Attach…")
self.attach_button.setIcon(_button_icon("attach"))
self.attach_button.clicked.connect(self._on_attach_clicked)
@@ -926,6 +947,7 @@ class ChatWindow(QMainWindow):
else:
self.hide_status()
self.update_cost()
+ self._update_record_button()
def current_model(self) -> str:
return self.model_box.currentData() or self.model_box.currentText()
@@ -1004,6 +1026,7 @@ class ChatWindow(QMainWindow):
if self.session_id is not None and not self.read_only:
self.history.touch_session(self.session_id, self.current_model())
self.update_meter()
+ self._update_record_button()
# -- system prompts ---------------------------------------------------
@@ -1179,7 +1202,7 @@ class ChatWindow(QMainWindow):
return
names = []
for att in self.attachments:
- mark = "πŸ–Ό" if att.kind == "image" else "πŸ“„"
+ mark = {"image": "πŸ–Ό", "audio": "πŸŽ™"}.get(att.kind, "πŸ“„")
suffix = " (truncated)" if att.truncated else ""
names.append(f"{mark} {att.path.name}{suffix}")
self.attach_label.setText("Attached: " + ", ".join(names))
@@ -1187,6 +1210,57 @@ class ChatWindow(QMainWindow):
self.clear_attach_button.show()
self.update_meter()
+ # -- recording --------------------------------------------------------
+
+ def _update_record_button(self) -> None:
+ """Show the record control only for an audio-capable model."""
+ if not hasattr(self, "record_button"):
+ return # still building the window
+ can = self.recorder is not None and self.current_info().audio is True
+ self.record_button.setVisible(can)
+ self.record_button.setText("Stop" if self.recording else "Record")
+ self.record_button.setIcon(
+ _button_icon("stop" if self.recording else "record")
+ )
+
+ def _toggle_record(self) -> None:
+ """Start a recording, or finish the one in progress."""
+ if self.recording:
+ self._finish_recording()
+ return
+ if self.recorder is None or self.current_info().audio is not True:
+ return
+ if not self.recorder.start():
+ self.show_status("No microphone available.", error=True)
+ return
+ self.recording = True
+ self.show_status("Recording. Click Stop when done.")
+ self._update_record_button()
+
+ @Slot()
+ def _finish_recording(self) -> None:
+ """Stop capture and hold the clip as a pending attachment."""
+ if not self.recording:
+ return
+ pcm = self.recorder.stop()
+ self.recording = False
+ self._update_record_button()
+
+ seconds = len(pcm) / (audio.RATE * audio.CHANNELS * audio.SAMPLE_BYTES)
+ if seconds < audio.MIN_SECONDS:
+ self.show_status("Recording too short, discarded.", error=True)
+ return
+ if self.recorder.timed_out:
+ self.show_status(
+ f"Recording stopped at the {audio.MAX_SECONDS}s limit."
+ )
+ else:
+ self.hide_status()
+ self.attachments.append(
+ backend.audio_attachment(audio.to_wav(pcm), round(seconds))
+ )
+ self.update_attach_label()
+
# -- drag and drop ----------------------------------------------------
def dragEnterEvent(self, event) -> None:
@@ -2346,6 +2420,10 @@ def _bubble_html(
elif saved_rows:
parts = []
for row in saved_rows:
+ if row["kind"] == "audio":
+ # A recording is discarded, so there is no file to check for.
+ parts.append("πŸŽ™ " + html.escape(row["path"]))
+ continue
missing = "" if Path(row["path"]).exists() else " (missing)"
parts.append(html.escape(row["path"]) + missing)
files = f"<br><i>files: {', '.join(parts)}</i>"
diff --git a/test_llamachat.py b/test_llamachat.py
index 04c1bc5..20aea57 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -497,6 +497,29 @@ def test_markdown_rendering():
print("ok markdown rendering")
+def test_audio_bubble_marker():
+ """A discarded recording shows as a voice note, not a missing file."""
+ import os
+
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat.ui import _bubble_html
+
+ app = QApplication.instance() or QApplication([])
+ assert app is not None
+
+ rendered = _bubble_html(
+ "user",
+ "Listen to this audio and respond.",
+ saved_rows=[{"kind": "audio", "path": "voice-note-4s.wav"}],
+ )
+ assert "πŸŽ™ voice-note-4s.wav" in rendered
+ # The synthetic path is not on disk, so it must not read as missing.
+ assert "missing" not in rendered
+ print("ok audio bubble marker")
+
+
def test_unbalanced_backticks():
"""An odd backtick cannot reflow the rest of a reply as code."""
import os
@@ -4041,6 +4064,7 @@ if __name__ == "__main__":
test_session_prompt_storage()
test_prompt_column_migration()
test_markdown_rendering()
+ test_audio_bubble_marker()
test_unbalanced_backticks()
test_sidebar_toggle()
test_shortcuts()