diff --git a/README.md b/README.md index 58042b5..a3bed8b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past - **One key per language** (default: `SUPER ALT D` for English; set your own in Settings). Press to start, press again to stop and paste. Esc discards. - **+ Return** per language: also press Return after pasting (for chat boxes and prompts). - **Ask your agent**: a second key per language hands the text to Omarchy's default coding agent (`omarchy default agent`) instead of pasting it. -- **Live waveform and live text** in the bar: yellow while the microphone connects, green while it listens; the bar goes back to the icon the moment the text is pasted. +- **Live waveform and live text** in the bar, in the language you are dictating: yellow while the microphone opens, green once your voice is actually coming through; the bar goes back to the icon the moment the text is pasted. Pick the look in Settings: bars, wave, pulse or dots. +- **Never miss the first words**: Settings → Advanced → "Keep the microphone open" keeps the stream running between recordings, so a recording starts instantly and even includes the half second before the key press. (Not for a Bluetooth headset: it would stay in headset mode all the time.) - **Stop is instant**: the recording is transcribed at every pause while you talk, so only the last phrase is left when you stop. - **History** of every recording (text + audio) with play, copy and delete, searchable, in the bar popup; kept for a month by default (Settings). - **Languages** picked from Whisper's list; the model a language needs is downloaded by itself the first time (about 150 MB for the default model; the bar shows the progress). English-only models are swapped for the multilingual one. diff --git a/daemon/sttd.py b/daemon/sttd.py index 13cafc2..902b045 100755 --- a/daemon/sttd.py +++ b/daemon/sttd.py @@ -58,6 +58,38 @@ LANGUAGES = { "ha": "Hausa", "so": "Somali", "uz": "Uzbek", "tg": "Tajik", "be": "Belarusian", "bs": "Bosnian", "mt": "Maltese", "ga": "Irish", "la": "Latin", "yi": "Yiddish", "mi": "Maori", "haw": "Hawaiian", "jw": "Javanese", "su": "Sundanese", } +# What the bar says while recording, in the language being dictated. +UI_STRINGS = { + "en": {"listening": "Listening…", "opening": "Opening microphone…", "transcribing": "Transcribing…"}, + "pt": {"listening": "Ouvindo…", "opening": "Abrindo o microfone…", "transcribing": "Transcrevendo…"}, + "es": {"listening": "Escuchando…", "opening": "Abriendo el micrófono…", "transcribing": "Transcribiendo…"}, + "fr": {"listening": "À l’écoute…", "opening": "Ouverture du micro…", "transcribing": "Transcription…"}, + "de": {"listening": "Ich höre zu…", "opening": "Mikrofon wird geöffnet…", "transcribing": "Transkribiere…"}, + "it": {"listening": "In ascolto…", "opening": "Apertura del microfono…", "transcribing": "Trascrizione…"}, + "nl": {"listening": "Luistert…", "opening": "Microfoon openen…", "transcribing": "Transcriberen…"}, + "pl": {"listening": "Słucham…", "opening": "Otwieranie mikrofonu…", "transcribing": "Transkrypcja…"}, + "sv": {"listening": "Lyssnar…", "opening": "Öppnar mikrofonen…", "transcribing": "Transkriberar…"}, + "da": {"listening": "Lytter…", "opening": "Åbner mikrofonen…", "transcribing": "Transskriberer…"}, + "no": {"listening": "Lytter…", "opening": "Åpner mikrofonen…", "transcribing": "Transkriberer…"}, + "fi": {"listening": "Kuuntelee…", "opening": "Avataan mikrofonia…", "transcribing": "Litteroidaan…"}, + "tr": {"listening": "Dinliyor…", "opening": "Mikrofon açılıyor…", "transcribing": "Yazıya dökülüyor…"}, + "ru": {"listening": "Слушаю…", "opening": "Открываю микрофон…", "transcribing": "Расшифровка…"}, + "uk": {"listening": "Слухаю…", "opening": "Відкриваю мікрофон…", "transcribing": "Розшифровка…"}, + "cs": {"listening": "Poslouchám…", "opening": "Otevírám mikrofon…", "transcribing": "Přepisuji…"}, + "el": {"listening": "Ακούω…", "opening": "Άνοιγμα μικροφώνου…", "transcribing": "Απομαγνητοφώνηση…"}, + "he": {"listening": "מקשיב…", "opening": "פותח מיקרופון…", "transcribing": "מתמלל…"}, + "ar": {"listening": "أستمع…", "opening": "جارٍ فتح الميكروفون…", "transcribing": "جارٍ التفريغ…"}, + "hi": {"listening": "सुन रहा है…", "opening": "माइक्रोफ़ोन खुल रहा है…", "transcribing": "लिख रहा है…"}, + "ja": {"listening": "聞いています…", "opening": "マイクを開いています…", "transcribing": "文字起こし中…"}, + "zh": {"listening": "正在聆听…", "opening": "正在打开麦克风…", "transcribing": "正在转写…"}, + "ko": {"listening": "듣고 있습니다…", "opening": "마이크 여는 중…", "transcribing": "받아쓰는 중…"}, + "id": {"listening": "Mendengarkan…", "opening": "Membuka mikrofon…", "transcribing": "Menyalin…"}, + "vi": {"listening": "Đang nghe…", "opening": "Đang mở micrô…", "transcribing": "Đang ghi lại…"}, + "th": {"listening": "กำลังฟัง…", "opening": "กำลังเปิดไมโครโฟน…", "transcribing": "กำลังถอดความ…"}, + "hu": {"listening": "Hallgatom…", "opening": "Mikrofon megnyitása…", "transcribing": "Átírás…"}, + "ro": {"listening": "Ascult…", "opening": "Deschid microfonul…", "transcribing": "Transcriu…"}, + "ca": {"listening": "Escoltant…", "opening": "Obrint el micròfon…", "transcribing": "Transcrivint…"}, +} VOXTYPE_MODELS = os.environ.get("STT_MODELS_DIR") or os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local", "share")), "voxtype", "models") MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{model}.bin" @@ -79,6 +111,9 @@ DEFAULT_CONFIG = { "restoreClipboard": True, "maxDurationSecs": 300, "device": "default", + "animation": "bars", # what the bar shows while recording: bars | wave | pulse | dots + "warmMic": False, # keep the microphone stream open between recordings: instant start + pre-roll + "prerollMs": 600, # audio from just before the key press that a warm microphone keeps "cancelKey": "ESCAPE", "notify": True, } @@ -434,7 +469,9 @@ class Recorder: self.lock = threading.Lock() self.started = 0.0 self.error = "" - self.listening = False # first audio chunk has arrived (pw-record takes a moment to connect) + self.listening = False # real audio is arriving (a Bluetooth mic sends silence while it switches profile) + self.chunks = 0 + self.standby = False # warm mode: the stream runs but only the last second is kept def start(self): fake = os.environ.get("STT_FAKE_INPUT") # tests: stream a 16 kHz mono wav at real-time pace instead of the mic @@ -459,29 +496,62 @@ class Recorder: data = out.read(CHUNK) if not data: break - self.listening = True n = len(data) // 2 samples = struct.unpack(f"<{n}h", data[: n * 2]) rms = math.sqrt(sum(s * s for s in samples) / max(1, n)) / 32768.0 level = min(1.0, math.sqrt(rms * 12.0)) # perceptual-ish: speech at normal level fills most of the bar + self.chunks += 1 + # Green means "your voice is getting through": any real signal (room noise counts), + # or 2 s of chunks for a microphone that is digitally silent. + if rms > 0.0005 or self.chunks >= 40: + self.listening = True with self.lock: self.buf += data - self.levels.append(round(level, 3)) # one per 50 ms; indexed by absolute offset, so never trimmed + self.levels.append(round(level, 3)) # one per 50 ms; indexed by absolute offset, never trimmed while recording + if self.standby and len(self.buf) > 3 * RATE * 2: # warm: keep only the last 1.5 s + drop = len(self.buf) - int(1.5 * RATE) * 2 + drop -= drop % CHUNK + del self.buf[:drop] + del self.levels[: drop // CHUNK] err = self.proc.stderr.read().decode(errors="replace").strip() rc = self.proc.wait() if rc not in (0, -15, -9) and err: self.error = err.splitlines()[-1] def stop(self): + """Blocking (up to ~1 s): call it from an executor, never on the event loop.""" if self.proc and self.proc.poll() is None: self.proc.terminate() try: - self.proc.wait(timeout=2) + self.proc.wait(timeout=0.5) except subprocess.TimeoutExpired: self.proc.kill() self.proc.wait() if self.thread: - self.thread.join(timeout=2) + self.thread.join(timeout=1) + + @property + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def park(self): + """Warm mode: keep the stream open between recordings, remembering only the last moment.""" + with self.lock: + self.standby = True + + def begin(self, preroll_secs): + """Turn a parked stream into a recording, keeping `preroll_secs` of what was just heard.""" + keep = int(preroll_secs * RATE) * 2 + keep -= keep % CHUNK + with self.lock: + self.standby = False + if keep and len(self.buf) > keep: + drop = len(self.buf) - keep + del self.buf[:drop] + del self.levels[: drop // CHUNK] + kept = len(self.buf) + self.started = time.time() - kept / 2 / RATE + self.error = "" @property def duration(self): @@ -920,6 +990,7 @@ class Daemon: self.sources = audio_sources() self.missing = missing_tools(self.cfg) self.error_clear = None # timer handle: errors fade by themselves + self.warm = None # a parked Recorder (warmMic): the stream is already open when the key is pressed self.loop = None self.stopping = False self.stop_event = None @@ -951,6 +1022,8 @@ class Daemon: "agentName": self.agent, "agentMode": self.agent_mode if self.state != "idle" else False, "missing": self.missing, + "strings": UI_STRINGS.get(self.lang["code"], UI_STRINGS["en"]), + "warm": bool(self.warm and self.warm.alive), } if full: msg["languageNames"] = LANGUAGES @@ -1082,6 +1155,43 @@ class Daemon: self.download_task = None self.ensure_models() # next one, if any + # ---- warm microphone ---- + def ensure_warm(self): + """With warmMic on, keep a parked stream ready whenever nothing is recording.""" + if self.stopping or self.state != "idle": + return + if not self.cfg.get("warmMic"): + if self.warm: + w, self.warm = self.warm, None + self.loop.run_in_executor(None, w.stop) + return + if self.warm and self.warm.alive: + return + if self.warm: + self.loop.run_in_executor(None, self.warm.stop) + if self.missing or not which("pw-record"): + self.warm = None + return + rec = Recorder(self.cfg.get("device", "default")) + try: + rec.start() + except (OSError, ValueError, TypeError) as e: + log("warm microphone failed:", e) + self.warm = None + return + rec.park() + self.warm = rec + + async def release_rec(self, rec): + """A recording is over: park the stream (warm) or close it (off the loop: pw-record can take a moment to die).""" + if self.cfg.get("warmMic") and rec.alive and not self.stopping: + rec.park() + self.warm = rec + else: + if self.warm is rec: + self.warm = None + await self.loop.run_in_executor(None, rec.stop) + async def start(self, code=None, agent=False): if self.state != "idle": self.broadcast() @@ -1105,14 +1215,23 @@ class Daemon: self.committed = [] self.committed_off = 0 self.tail_text = "" - self.rec = Recorder(self.cfg.get("device", "default")) - try: - self.rec.start() - except (OSError, ValueError, TypeError) as e: - self.rec = None - self.fail(f"cannot record: {e}") - self.broadcast() - return + if self.warm and self.warm.alive and self.warm.device == self.cfg.get("device", "default"): + # The stream is already open: no start-up gap, and the moment before the key press comes along. + self.rec = self.warm + self.warm = None + self.rec.begin(max(0, int(self.cfg.get("prerollMs", 600))) / 1000) + else: + if self.warm: + self.warm.stop() + self.warm = None + self.rec = Recorder(self.cfg.get("device", "default")) + try: + self.rec.start() + except (OSError, ValueError, TypeError) as e: + self.rec = None + self.fail(f"cannot record: {e}") + self.broadcast() + return self.state = "recording" self.binds.set_cancel(self.cfg, True) self.broadcast() @@ -1172,9 +1291,9 @@ class Daemon: return (n - 100 + j) * CHUNK return None - async def _transcribe_range(self, rec, a, b, prefix): - """Run the engine on buf[a:b] (under the engine lock). Returns text ('' for noise) or None on error.""" - pcm = rec.snapshot_range(a, b) + async def _transcribe_range(self, audio, a, b, prefix): + """Run the engine on audio[a:b] (under the engine lock). Returns text ('' for noise) or None on error.""" + pcm = bytes(audio[a:b]) if len(pcm) < int(0.3 * RATE) * 2: return "" fd, tmp = tempfile.mkstemp(prefix=prefix, suffix=".wav", dir=RUNTIME) @@ -1202,10 +1321,11 @@ class Daemon: await asyncio.sleep(interval) while self.state == "recording" and self.rec is rec: levels = rec.levels_copy() + audio = rec.snapshot() cut = self._find_cut(levels) if cut is not None and cut > self.committed_off: a = self.committed_off - text = await self._transcribe_range(rec, a, cut, "live-") if self._voiced(levels, a, cut) else "" + text = await self._transcribe_range(audio, a, cut, "live-") if self._voiced(levels, a, cut) else "" if self.rec is not rec: return if text is not None: # on an engine error the segment stays uncommitted and is retried @@ -1214,9 +1334,9 @@ class Daemon: self.committed_off = cut self.tail_text = "" else: - a, b = self.committed_off, rec.size + a, b = self.committed_off, len(audio) if b - a > int(0.8 * RATE) * 2 and self._voiced(levels, a, b): - text = await self._transcribe_range(rec, a, b, "live-") + text = await self._transcribe_range(audio, a, b, "live-") if self.rec is not rec: return if text: @@ -1230,8 +1350,11 @@ class Daemon: self.broadcast() return rec = self.rec - rec.stop() + pcm = rec.snapshot() # frozen now: a parked stream keeps only its last moment + levels = rec.levels_copy() + started = rec.started self.state = "transcribing" + await self.release_rec(rec) self.enter_pending = bool(enter) agent = bool(agent) or self.agent_mode lang = self.lang @@ -1239,9 +1362,7 @@ class Daemon: self.broadcast() path = "" try: - pcm = rec.snapshot() duration = len(pcm) / 2 / RATE - started = rec.started stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(started)) path = os.path.join(TAKES, f"{stamp}-{lang['code']}.wav") write_wav(path, pcm) @@ -1257,7 +1378,7 @@ class Daemon: raise asyncio.CancelledError committed, off = list(self.committed), self.committed_off a, b = off, len(pcm) - tail = await self._transcribe_range(rec, a, b, "final-") if self._voiced(rec.levels_copy(), a, b) else "" + tail = await self._transcribe_range(pcm, a, b, "final-") if self._voiced(levels, a, b) else "" if tail is None: text, err = "", self.error else: @@ -1300,6 +1421,7 @@ class Daemon: self.state = "idle" self.rec = None self.partial = "" + self.ensure_warm() self.broadcast() @staticmethod @@ -1313,14 +1435,17 @@ class Daemon: if self.state == "idle": self.broadcast() return - if self.rec: - self.rec.stop() - EngineRun.kill() # a transcription in flight is for a recording nobody wants + rec = self.rec self.state = "idle" self.rec = None self.partial = "" + EngineRun.kill() # a transcription in flight is for a recording nobody wants self.binds.set_cancel(self.cfg, False) self.broadcast() + if rec: + await self.release_rec(rec) + self.ensure_warm() + self.broadcast() async def toggle(self, code=None, enter=False, agent=False): if self.state == "idle": @@ -1408,6 +1533,10 @@ class Daemon: self.refresh_environment() if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)): self.broadcast({"type": "history-changed"}) + if self.warm and ("device" in (patch or {}) or not self.cfg.get("warmMic")): + w, self.warm = self.warm, None + self.loop.run_in_executor(None, w.stop) + self.ensure_warm() self.ensure_models() # ---- socket ---- @@ -1556,12 +1685,22 @@ class Daemon: pass await asyncio.sleep(5) + async def warm_watch(self): + """A parked stream can die (device unplugged, headset off): reopen it when it does.""" + while not self.stopping: + await asyncio.sleep(3) + if self.cfg.get("warmMic") and self.state == "idle" and (not self.warm or not self.warm.alive): + self.ensure_warm() + async def shutdown(self): if self.stopping: return self.stopping = True if self.rec: self.rec.stop() + if self.warm: + self.warm.stop() + self.warm = None EngineRun.kill() if self.download_task and not self.download_task.done(): self.download_task.cancel() @@ -1592,8 +1731,10 @@ class Daemon: self.binds.apply(self.cfg) if self.history.prune(self.cfg.get("historyDays", 30)): log("history pruned") + self.ensure_warm() self.ensure_models() self.loop.create_task(self.hypr_events()) + self.loop.create_task(self.warm_watch()) for s in (signal.SIGTERM, signal.SIGINT): self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown())) log(f"sttd {VERSION} listening on {SOCK}") diff --git a/plugin/Panel.qml b/plugin/Panel.qml index cdc2bbd..c8ac142 100644 --- a/plugin/Panel.qml +++ b/plugin/Panel.qml @@ -106,6 +106,7 @@ Panel { return "" } readonly property string defaultLang: langs.length ? langs[0].code : "" + function t(key, fallback) { var st = svc ? svc.strings : null; return st && st[key] ? st[key] : fallback } readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent" function clock(secs) { var s = Math.floor(secs || 0) @@ -122,17 +123,17 @@ Panel { function subtitle() { if (!svc) return "Service not loaded" if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…" - if (connecting) return "Opening the microphone…" - if (recording) return "Listening · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "") - if (transcribing) return "Transcribing…" + if (connecting) return t("opening", "Opening the microphone…") + if (recording) return t("listening", "Listening…").replace(/…$/, "") + " · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "") + if (transcribing) return t("transcribing", "Transcribing…") if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%" return "Idle · " + svc.langLabel } readonly property string liveText: { if (!svc) return "" - if (connecting) return "Opening microphone…" - if (recording) return svc.partial !== "" ? svc.partial : "Listening…" - if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : "Transcribing…") + if (connecting) return t("opening", "Opening microphone…") + if (recording) return svc.partial !== "" ? svc.partial : t("listening", "Listening…") + if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : t("transcribing", "Transcribing…")) if (showError) return svc.error if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%" return "" @@ -173,6 +174,7 @@ Panel { Waveform { anchors.verticalCenter: parent.verticalCenter height: Style.bar.iconCanvas + style: ["bars", "wave", "pulse", "dots"].indexOf(String(root.cfg.animation)) >= 0 ? String(root.cfg.animation) : "bars" bars: 18 levels: root.svc ? root.svc.levels : [] sine: root.connecting || (root.download !== null && !root.busy) @@ -1104,6 +1106,21 @@ Panel { checked: root.cfg.keepAudio !== false onToggled: if (root.svc) root.svc.setSetting("keepAudio", root.cfg.keepAudio === false) } + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Bar animation" } + Dropdown { + width: parent.width - root.labelW - parent.spacing - root.trailInset + showLabel: false + enabled: root.connected + value: String(root.cfg.animation || "bars") + options: [ { value: "bars", label: "Bars" }, { value: "wave", label: "Wave" }, { value: "pulse", label: "Pulse" }, { value: "dots", label: "Dots" } ] + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { if (root.svc) root.svc.setSetting("animation", v); value = Qt.binding(function() { return String(root.cfg.animation || "bars") }) } + } + } Row { width: parent.width spacing: Style.space(8) @@ -1265,6 +1282,13 @@ Panel { } } Note { text: "A Bluetooth headset switches to its low-quality headset profile while its microphone is open, which pauses or degrades whatever it is playing. Pick another microphone here to avoid that." } + SwitchRow { + label: "Keep the microphone open" + summary: checked ? "instant start, half a second of pre-roll" : "opens on each key press" + checked: !!root.cfg.warmMic + onToggled: if (root.svc) root.svc.setSetting("warmMic", !root.cfg.warmMic) + } + Note { text: "The stream stays open between recordings, so the first words are never missed: the recording even includes the moment before the key press. With a Bluetooth headset this keeps it in headset mode all the time, so pair it with a wired or USB microphone above." } Row { width: parent.width spacing: Style.space(8) diff --git a/plugin/Service.qml b/plugin/Service.qml index bd626c9..da80c67 100644 --- a/plugin/Service.qml +++ b/plugin/Service.qml @@ -39,6 +39,8 @@ Item { readonly property string agentName: state.agentName || "" // omarchy's default coding agent readonly property bool agentMode: !!state.agentMode // this recording goes to the agent readonly property var missing: state.missing || [] // tools a stock machine still lacks (voxtype, wtype…) + readonly property var strings: state.strings || ({}) // bar wording in the language being dictated + readonly property bool warm: !!state.warm // the microphone stream is kept open between recordings property var sources: [] // microphones (sent with the greeting and on `get`) // ---- history (fetched on demand; the daemon says when it changed) ---- diff --git a/plugin/Waveform.qml b/plugin/Waveform.qml index f19a825..822346a 100644 --- a/plugin/Waveform.qml +++ b/plugin/Waveform.qml @@ -1,30 +1,39 @@ import QtQuick -// A row of thin bars mirrored around the middle. `levels` holds 0..1 values, -// newest last; the bars show the most recent `bars` of them. With `idle` on -// (nothing to show yet, or transcribing) the bars breathe gently instead. +// The bar's picture of the microphone, in one of four styles: +// bars thin bars mirrored around the middle (the default) +// wave a smooth sine-like line whose swell follows the voice +// pulse a dot inside a ring that breathes with the voice +// dots a row of dots that grow with the voice +// `levels` holds 0..1 values, newest last. With `sine` on (the microphone is +// still opening) every style shows a travelling wave; with `idle` on +// (transcribing, or an error) it breathes gently instead. Item { id: root property var levels: [] + property string style: "bars" property int bars: 20 property real barWidth: 2 property real gap: 2 property color color: "white" - property bool idle: false // gentle breathing (nothing to show yet, or transcribing) - property bool sine: false // a travelling sine wave (the microphone is still connecting) + property bool idle: false + property bool sine: false property real minHeight: 2 - implicitWidth: bars * (barWidth + gap) - gap + readonly property bool animated: idle || sine || style === "wave" || style === "pulse" + implicitWidth: style === "pulse" ? height + 6 : bars * (barWidth + gap) - gap implicitHeight: 16 property real phase: 0 Timer { interval: 40 repeat: true - running: (root.idle || root.sine) && root.visible - onTriggered: root.phase += root.sine ? 0.35 : 0.2 + running: root.animated && root.visible + onTriggered: { root.phase += root.sine ? 0.35 : 0.2; if (root.style === "wave") waveCanvas.requestPaint() } } + onLevelsChanged: if (style === "wave") waveCanvas.requestPaint() + onStyleChanged: if (style === "wave") waveCanvas.requestPaint() function levelAt(i) { var lv = levels || [] @@ -32,23 +41,28 @@ Item { var v = offset + i >= 0 ? Number(lv[offset + i]) : 0 return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0 } - - function breath(i) { - return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2 - } - - function wave(i) { - return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2 + function breath(i) { return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2 } + function travel(i) { return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2 } + function amp(i) { return sine ? travel(i) : idle ? breath(i) : levelAt(i) } + // The loudest of the last few levels, smoothed: what a single shape should show. + function recent() { + if (sine) return 0.4 + 0.35 * (1 + Math.sin(phase * 1.5)) / 2 + if (idle) return 0.15 + 0.1 * (1 + Math.sin(phase)) / 2 + var lv = levels || [], m = 0 + for (var i = Math.max(0, lv.length - 4); i < lv.length; i++) m = Math.max(m, Number(lv[i]) || 0) + return Math.min(1, m) } + // ---- bars ---- Row { + visible: root.style === "bars" anchors.centerIn: parent spacing: root.gap Repeater { - model: root.bars + model: root.style === "bars" ? root.bars : 0 Rectangle { required property int index - readonly property real lv: root.sine ? root.wave(index) : root.idle ? root.breath(index) : root.levelAt(index) + readonly property real lv: root.amp(index) width: root.barWidth height: Math.max(root.minHeight, Math.round(lv * root.height)) radius: root.barWidth / 2 @@ -58,4 +72,86 @@ Item { } } } + + // ---- dots ---- + Row { + visible: root.style === "dots" + anchors.centerIn: parent + spacing: 0 + readonly property int count: Math.max(4, Math.round(root.bars / 2)) + readonly property real slot: root.implicitWidth / count + Repeater { + model: root.style === "dots" ? parent.count : 0 + Item { + required property int index + readonly property real lv: root.amp(Math.min(root.bars - 1, Math.round(index * root.bars / parent.count + root.bars / parent.count / 2))) + width: parent.slot + height: root.height + Rectangle { + anchors.centerIn: parent + width: Math.max(2, Math.round(2 + lv * (Math.min(root.height, parent.width) - 3))) + height: width + radius: width / 2 + color: root.color + opacity: 0.5 + 0.5 * lv + Behavior on width { NumberAnimation { duration: 70 } } + } + } + } + } + + // ---- wave ---- + Canvas { + id: waveCanvas + visible: root.style === "wave" + anchors.fill: parent + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (!visible) return + var mid = height / 2, n = root.bars, span = width / Math.max(1, n - 1) + ctx.strokeStyle = root.color + ctx.lineWidth = 1.6 + ctx.lineCap = "round" + ctx.beginPath() + var steps = Math.max(24, Math.round(width)) + for (var s = 0; s <= steps; s++) { + var x = width * s / steps + var pos = x / span, i = Math.floor(pos), f = pos - i + var a0 = root.amp(Math.min(n - 1, i)), a1 = root.amp(Math.min(n - 1, i + 1)) + var envelope = a0 + (a1 - a0) * f + var y = mid + Math.sin(x * 0.55 - root.phase * 2) * envelope * (mid - 1) + if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y) + } + ctx.stroke() + } + } + + // ---- pulse ---- + Item { + visible: root.style === "pulse" + anchors.centerIn: parent + width: root.height + 6 + height: root.height + readonly property real lv: root.recent() + Rectangle { // ring + anchors.centerIn: parent + width: Math.round(root.height * (0.45 + 0.55 * parent.lv)) + height: width + radius: width / 2 + color: "transparent" + border.width: 1.5 + border.color: root.color + opacity: 0.35 + 0.65 * parent.lv + Behavior on width { NumberAnimation { duration: 90 } } + } + Rectangle { // dot + anchors.centerIn: parent + width: Math.round(root.height * (0.2 + 0.3 * parent.lv)) + height: width + radius: width / 2 + color: root.color + Behavior on width { NumberAnimation { duration: 60 } } + } + } }