Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 |
@@ -114,6 +114,20 @@ const transcribingVoice = ref(false)
|
||||
const recorder = useVoiceRecorder()
|
||||
const silenceDetector = useSilenceDetector()
|
||||
|
||||
// Live mic halo. A solid red disc sits behind the mic button and scales
|
||||
// with `silenceDetector.amplitude` (0..1 RMS, already amplified for
|
||||
// visibility). The button itself stays put so the mic icon is always
|
||||
// legible on top. 0.2 baseline breathes on silence; loud speech drives
|
||||
// the disc out to ~2.6× the button size with a softer radial fade.
|
||||
const micGlowStyle = computed(() => {
|
||||
if (!recorder.recording.value) return { display: 'none' }
|
||||
const pulse = 0.2 + Math.min(silenceDetector.amplitude.value, 1) * 0.8
|
||||
return {
|
||||
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
|
||||
opacity: String(0.45 + pulse * 0.4),
|
||||
}
|
||||
})
|
||||
|
||||
async function toggleVoice() {
|
||||
if (transcribingVoice.value) return
|
||||
if (recorder.recording.value) {
|
||||
@@ -230,23 +244,25 @@ defineExpose({ focus, prefill })
|
||||
class="input-textarea"
|
||||
></textarea>
|
||||
|
||||
<!-- PTT mic -->
|
||||
<button
|
||||
v-if="voiceEnabled"
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<!-- PTT mic (with live amplitude halo behind) -->
|
||||
<div v-if="voiceEnabled" class="mic-wrapper">
|
||||
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
|
||||
<button
|
||||
class="btn-icon btn-mic"
|
||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
|
||||
@click.prevent="toggleVoice"
|
||||
:disabled="transcribingVoice || !store.chatReady"
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Abort (streaming) or Send -->
|
||||
<button
|
||||
@@ -343,9 +359,50 @@ defineExpose({ focus, prefill })
|
||||
.btn-icon:hover { opacity: 1; }
|
||||
.btn-icon:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
|
||||
.btn-mic.mic-recording {
|
||||
opacity: 1;
|
||||
/* White icon sits on top of the red halo so it stays legible at any
|
||||
pulse size. */
|
||||
color: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
|
||||
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
||||
absolutely positioned behind the button, scaled/faded by the
|
||||
computed `micGlowStyle` so the user gets unmistakable feedback
|
||||
that their voice is being picked up while the mic icon itself
|
||||
stays put and readable on top. */
|
||||
.mic-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mic-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(239, 68, 68, 0.9) 0%,
|
||||
rgba(239, 68, 68, 0.6) 55%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
|
||||
rather than stepping. */
|
||||
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.note-picker-wrapper { position: relative; }
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
/**
|
||||
* Absolute fallback silence threshold in dBFS, used during the first
|
||||
* moments before any real speech has been observed. Once the session peak
|
||||
* clears `dynamicArmDb` we switch to the dynamic threshold instead.
|
||||
*/
|
||||
fallbackThresholdDb?: number // default -45
|
||||
/** How many dB below the session peak counts as "silent" once armed. */
|
||||
dropFromPeakDb?: number // default 15
|
||||
/**
|
||||
* Session peak must reach this level (dBFS) before dynamic thresholding
|
||||
* kicks in. Until then the fallback threshold is used so we don't lock
|
||||
* onto a noise-floor peak.
|
||||
*/
|
||||
dynamicArmDb?: number // default -25
|
||||
/** How long to wait at the start of recording before running silence checks. */
|
||||
graceMs?: number // default 1500
|
||||
silenceDurationMs?: number // default 2000
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Mic silence detector + live amplitude signal.
|
||||
*
|
||||
* Uses `getFloatTimeDomainData()` for honest linear RMS in [-1, 1] space,
|
||||
* then derives dBFS for the silence threshold. The previous implementation
|
||||
* ran RMS over `getByteFrequencyData` bytes — those bytes are already a
|
||||
* dB-scaled quantity, so taking their RMS and re-log'ing it produced
|
||||
* numbers that didn't line up with real dBFS and made any static threshold
|
||||
* unpredictable.
|
||||
*
|
||||
* Silence threshold is dynamic: we track the session peak dBFS and treat
|
||||
* "silent" as "current level is at least [dropFromPeakDb] below peak."
|
||||
* This auto-calibrates to whatever mic and room the user is on. Until the
|
||||
* peak climbs above [dynamicArmDb] we fall back to a conservative static
|
||||
* threshold so a quiet room doesn't spin forever. A grace period at the
|
||||
* start of the recording gives the user time to begin speaking before
|
||||
* silence checks arm.
|
||||
*
|
||||
* `amplitude` is the raw linear RMS scaled ×5 and clamped to 1 so quiet
|
||||
* speech still visibly moves UI that binds to it.
|
||||
*/
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
fallbackThresholdDb = -45,
|
||||
dropFromPeakDb = 15,
|
||||
dynamicArmDb = -25,
|
||||
graceMs = 1500,
|
||||
silenceDurationMs = 2000,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
@@ -18,31 +57,43 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
let peakDb = -100
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
// Some browsers start AudioContext in "suspended" state — resume so
|
||||
// getByteFrequencyData returns real values instead of all zeros.
|
||||
// the analyser returns real values instead of all zeros.
|
||||
audioCtx.resume().catch(() => {})
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
analyser.fftSize = 1024
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
const samples = new Float32Array(analyser.fftSize)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
peakDb = -100
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
analyser.getFloatTimeDomainData(samples)
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
|
||||
const rms = Math.sqrt(sumSq / samples.length)
|
||||
amplitude.value = Math.min(rms * 5, 1)
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
const db = rms > 1e-7 ? 20 * Math.log10(rms) : -100
|
||||
if (db > peakDb) peakDb = db
|
||||
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (elapsed < graceMs) return
|
||||
|
||||
const threshold =
|
||||
peakDb > dynamicArmDb ? peakDb - dropFromPeakDb : fallbackThresholdDb
|
||||
|
||||
if (db < threshold) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
if (silenceMs >= silenceDurationMs && elapsed >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
@@ -63,6 +114,7 @@ export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
peakDb = -100
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
|
||||
@@ -537,10 +537,16 @@ async def discuss_article(item_id: int):
|
||||
# exchange and the conversational seed user prompt into the conversation.
|
||||
# The /news from-article route calls the same helper so behavior stays
|
||||
# byte-identical across entry points.
|
||||
from fabledassistant.services.article_context import seed_article_discussion
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
|
||||
@@ -521,7 +521,10 @@ async def create_conversation_from_article(item_id: int):
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.article_context import seed_article_discussion
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
@@ -540,7 +543,16 @@ async def create_conversation_from_article(item_id: int):
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
||||
discuss_prompt = await seed_article_discussion(conv.id, item, model)
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv.id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
# Roll back the empty conversation so the user doesn't end up with a
|
||||
# phantom entry in their chat list.
|
||||
try:
|
||||
await delete_conversation(uid, conv.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to clean up empty article conversation %s", conv.id)
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation so we see the two messages the helper just added.
|
||||
conv = await get_conversation(uid, conv.id)
|
||||
|
||||
@@ -182,6 +182,15 @@ ARTICLE_DISCUSS_SEED = (
|
||||
)
|
||||
|
||||
|
||||
class EmptyArticleError(Exception):
|
||||
"""Raised when an article has no extractable body text.
|
||||
|
||||
Callers (the briefing and /news discuss routes) map this to a 422 so the
|
||||
user sees a clear error instead of a hallucinated summary built from an
|
||||
empty synthetic tool result.
|
||||
"""
|
||||
|
||||
|
||||
async def seed_article_discussion(
|
||||
conv_id: int,
|
||||
item: RssItem,
|
||||
@@ -213,15 +222,30 @@ async def seed_article_discussion(
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
if not raw_body.strip():
|
||||
# Hard-fail rather than stage an empty synthetic tool result.
|
||||
# An empty `content` field silently tells the model "the article
|
||||
# has nothing in it" and it confabulates from RAG/history. Better
|
||||
# to surface a clean error to the user.
|
||||
logger.warning(
|
||||
"Article discussion aborted: empty body for rss_item %s (%s)",
|
||||
item.id, item.url,
|
||||
)
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if article_content:
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
if not article_content.strip():
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
|
||||
@@ -724,18 +724,27 @@ async def build_context(
|
||||
orphan_only = rag_project_id is None
|
||||
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
|
||||
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
# Skip RAG auto-injection on the first turn of a seeded article discussion.
|
||||
# The article body is already the sole context the user wants — pulling in
|
||||
# unrelated orphan notes tricks the model into summarizing those instead.
|
||||
# Follow-up turns keep RAG on because by then the user's own messages drive
|
||||
# the query rather than the generic seed prompt.
|
||||
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
|
||||
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
|
||||
|
||||
if not found_scored:
|
||||
if not _skip_rag_for_article_seed:
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
|
||||
if not found_scored and not _skip_rag_for_article_seed:
|
||||
keywords = _extract_keywords(user_message)
|
||||
if keywords:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user