aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/backend.py58
-rwxr-xr-xtest_llamachat.py48
2 files changed, 98 insertions, 8 deletions
diff --git a/llamachat/backend.py b/llamachat/backend.py
index 70a98b1..b79040f 100644
--- a/llamachat/backend.py
+++ b/llamachat/backend.py
@@ -44,6 +44,14 @@ CODE_SUFFIXES = {
IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
+AUDIO_MIMES = {"audio/wav", "audio/x-wav"}
+AUDIO_SUFFIXES = {".wav"}
+
+# Used when a recording is sent with no typed text. llama.cpp pairs audio with
+# a text part, and this is the least presumptuous instruction that still tells
+# the model what the clip is.
+AUDIO_PROMPT = "Listen to this audio and respond."
+
# Sentinel yielded by the SSE parser at the end of a stream. A distinct
# object rather than '' so an empty delta cannot be mistaken for the end.
DONE = ("done", "")
@@ -102,15 +110,18 @@ class Attachment:
sha256: str
text: str = "" # kind == 'text': file content, possibly truncated
data_url: str = "" # kind == 'image': base64 data: URL for the API
+ b64: str = "" # kind == 'audio': raw base64 WAV (no data: prefix)
thumb: bytes | None = None
truncated: bool = False
def classify(path: Path) -> str:
- """Decide whether a path is a text or image attachment."""
+ """Decide whether a path is a text, image or audio attachment."""
mime, _ = mimetypes.guess_type(path.name)
if mime in IMAGE_MIMES:
return "image"
+ if mime in AUDIO_MIMES or path.suffix.lower() in AUDIO_SUFFIXES:
+ return "audio"
if path.suffix in CODE_SUFFIXES:
return "text"
if mime and mime.startswith("text/"):
@@ -148,6 +159,11 @@ def load_attachment(path: Path, char_budget: int) -> Attachment:
att.text = raw
return att
+ if kind == "audio":
+ raw = path.read_bytes()
+ att.b64 = base64.b64encode(raw).decode("ascii")
+ return att
+
raw = path.read_bytes()
att.data_url = f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
att.thumb = _thumbnail(path)
@@ -187,7 +203,8 @@ def build_user_content(text: str, attachments: list[Attachment]):
joined = "\n\n".join(parts)
images = [a for a in attachments if a.kind == "image"]
- if not images:
+ audios = [a for a in attachments if a.kind == "audio"]
+ if not images and not audios:
return joined
content: list[dict] = [{"type": "text", "text": joined}]
@@ -195,9 +212,33 @@ def build_user_content(text: str, attachments: list[Attachment]):
content.append(
{"type": "image_url", "image_url": {"url": att.data_url}}
)
+ for att in audios:
+ content.append(
+ {
+ "type": "input_audio",
+ "input_audio": {"data": att.b64, "format": "wav"},
+ }
+ )
return content
+def audio_attachment(wav: bytes, seconds: int) -> Attachment:
+ """An Attachment for a recorded clip.
+
+ The bytes are never written to disk: the model gets them in the request,
+ and the history row records only their size and hash. The name is
+ synthetic and carries the duration for display.
+ """
+ return Attachment(
+ path=Path(f"voice-note-{seconds}s.wav"),
+ kind="audio",
+ mime="audio/wav",
+ size=len(wav),
+ sha256=hashlib.sha256(wav).hexdigest(),
+ b64=base64.b64encode(wav).decode("ascii"),
+ )
+
+
class Client:
"""Minimal OpenAI-compatible client for the local router."""
@@ -689,12 +730,13 @@ def estimate_tokens(messages: list[dict], chars_per_token: float) -> int:
"""Rough token count for a request that has not been sent yet.
Used for the live meter while typing. Once a reply completes, the exact
- count from the usage block replaces this. Images are counted as a flat
- allowance rather than by dimension, which is enough to show that a
- picture is expensive without pretending to know the tiling.
+ count from the usage block replaces this. A non-text part (image or
+ audio) is counted as a flat allowance rather than by dimension, which is
+ enough to show that a picture is expensive without pretending to know
+ the tiling.
"""
chars = 0
- images = 0
+ media = 0
for message in messages:
chars += len(str(message.get("role", "")))
content = message.get("content")
@@ -705,10 +747,10 @@ def estimate_tokens(messages: list[dict], chars_per_token: float) -> int:
if part.get("type") == "text":
chars += len(part.get("text", ""))
else:
- images += 1
+ media += 1
# A few tokens per message go to the chat template's own markup.
overhead = 4 * len(messages)
- return int(chars / max(chars_per_token, 1.0)) + overhead + images * 600
+ return int(chars / max(chars_per_token, 1.0)) + overhead + media * 600
def _parse_sse_line(line: str):
diff --git a/test_llamachat.py b/test_llamachat.py
index 586be47..44f569e 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -229,12 +229,28 @@ def test_attachment_truncation():
print("ok attachment truncation")
+def test_audio_load():
+ """A .wav file loads as an audio attachment, not as text or unknown."""
+ with tempfile.TemporaryDirectory() as tmp:
+ src = Path(tmp) / "note.wav"
+ src.write_bytes(b"RIFF\x00\x00\x00\x00WAVEfmt ")
+ att = backend.load_attachment(src, 1000)
+ assert att.kind == "audio"
+ assert att.b64
+ assert not att.b64.startswith("data:")
+ assert att.thumb is None
+ assert att.text == ""
+ print("ok audio attachment load")
+
+
def test_classify():
assert backend.classify(Path("a.py")) == "text"
assert backend.classify(Path("a.SlackBuild")) == "text"
assert backend.classify(Path("a.png")) == "image"
assert backend.classify(Path("a.jpg")) == "image"
assert backend.classify(Path("a.so")) == "unknown"
+ assert backend.classify(Path("a.wav")) == "audio"
+ assert backend.classify(Path("a.WAV")) == "audio"
print("ok file classification")
@@ -376,6 +392,25 @@ def test_user_content():
assert content[0]["type"] == "text"
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"].startswith("data:image/png")
+
+ # Audio -> the input_audio part llama.cpp expects, raw base64. The
+ # data must not carry a data: prefix, unlike the image URL above.
+ rec = backend.audio_attachment(b"\x00\x01" * 8, seconds=2)
+ assert rec.kind == "audio"
+ assert rec.path.name == "voice-note-2s.wav"
+ assert rec.sha256 and rec.size == 16
+
+ content = backend.build_user_content("listen", [rec])
+ assert isinstance(content, list)
+ assert content[0] == {"type": "text", "text": "listen"}
+ assert content[1]["type"] == "input_audio"
+ assert content[1]["input_audio"]["format"] == "wav"
+ assert content[1]["input_audio"]["data"] == rec.b64
+ assert not content[1]["input_audio"]["data"].startswith("data:")
+
+ # Image and audio together keep both parts, text first.
+ both = backend.build_user_content("look and listen", [img, rec])
+ assert [p["type"] for p in both] == ["text", "image_url", "input_audio"]
print("ok user content assembly")
@@ -758,6 +793,18 @@ def test_token_estimate():
]
assert backend.estimate_tokens(with_image, 3.5) > 500
+ # An audio part costs like an image, not free.
+ with_audio = [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "listen"},
+ {"type": "input_audio", "input_audio": {"data": "AAA", "format": "wav"}},
+ ],
+ }
+ ]
+ assert backend.estimate_tokens(with_audio, 3.5) > 500
+
# A silly ratio must not divide by zero.
assert backend.estimate_tokens(small, 0) > 0
print("ok token estimate")
@@ -3867,6 +3914,7 @@ if __name__ == "__main__":
test_fts_query_escaping()
test_history_roundtrip()
test_attachment_truncation()
+ test_audio_load()
test_classify()
test_sse_parsing()
test_reasoning_storage()