diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 20:28:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 20:28:07 +0200 |
| commit | a830263c8fa51922ecc44a5eab7ce21d29a1ba6a (patch) | |
| tree | 3cd4853f92068f48ead25483e93b5d68732743a7 | |
| parent | 122e50fb67540e24cc49b880fd78ba26ba043281 (diff) | |
| download | llamachat-a830263c8fa51922ecc44a5eab7ce21d29a1ba6a.tar.gz llamachat-a830263c8fa51922ecc44a5eab7ce21d29a1ba6a.zip | |
feat: record control and voice-note history marker
| -rw-r--r-- | llamachat/ui.py | 80 | ||||
| -rwxr-xr-x | test_llamachat.py | 24 |
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() |
