mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Merge pull request #13 from legop3/fix-laptoprover-audio
Fix laptoprover tts and horn including gtts
This commit is contained in:
@@ -1,88 +1,98 @@
|
||||
# ALSA routing for the Debian laptop rover profile.
|
||||
# Dedicated ALSA routing for the Debian laptop rover profile.
|
||||
#
|
||||
# This file intentionally mirrors the logical device names used by the Pi rover
|
||||
# audio setup. roverd can keep sending horn audio to "horn", forwarded browser
|
||||
# audio to "forward", and TTS to ALSA's default playback path without caring
|
||||
# which physical sound card is underneath the profile.
|
||||
# This intentionally mirrors pi/asound.conf as closely as a normal PC can:
|
||||
# one fixed hardware card, one dmix playback engine, separate softvol controls
|
||||
# for TTS/horn/forwarded audio, and a raw capture alias for the rover mic.
|
||||
#
|
||||
# This is NOT meant to preserve normal desktop audio behavior. The laptop rover
|
||||
# installer disables PipeWire/PulseAudio so roverd owns the audio hardware like
|
||||
# the Raspberry Pi rover does. If the laptop's real speaker/mic card is not ALSA
|
||||
# card 0, change the hw:0,0/card 0 references below to the card shown by:
|
||||
# aplay -l
|
||||
# arecord -l
|
||||
|
||||
pcm.roverd_playback {
|
||||
type plug
|
||||
|
||||
# Use the system's first normal ALSA playback device as the physical sink.
|
||||
# This avoids referencing "default" here, because this file replaces
|
||||
# pcm.!default below and using it as a slave would recurse.
|
||||
slave.pcm "sysdefault"
|
||||
}
|
||||
|
||||
pcm.roverd_capture {
|
||||
type plug
|
||||
|
||||
# The media publisher records from "default"; with pcm.!default below that
|
||||
# capture side resolves here. Keeping capture separate from playback lets the
|
||||
# asym default expose ordinary microphone input while playback goes through
|
||||
# the TTS softvol path.
|
||||
slave.pcm "sysdefault"
|
||||
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||
pcm.dmixer {
|
||||
type dmix
|
||||
ipc_key 1024
|
||||
ipc_perm 0666
|
||||
slave {
|
||||
pcm "hw:0,0"
|
||||
format S16_LE
|
||||
rate 16000
|
||||
channels 1
|
||||
period_time 0
|
||||
period_size 1024
|
||||
buffer_size 4096
|
||||
}
|
||||
}
|
||||
|
||||
# TTS volume control (used by default playback path).
|
||||
pcm.tts_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Horn volume control.
|
||||
pcm.horn_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Forwarded audio volume control.
|
||||
pcm.forward_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Per-source playback PCMs.
|
||||
pcm.tts {
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
}
|
||||
|
||||
pcm.horn {
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
}
|
||||
|
||||
pcm.forward {
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
}
|
||||
|
||||
pcm.!default {
|
||||
type asym
|
||||
# Capture alias used by laptop rover config defaults.
|
||||
pcm.rovermic {
|
||||
type plug
|
||||
slave.pcm "hw:0,0"
|
||||
}
|
||||
|
||||
# Existing TTS engines play to their default ALSA output, so default playback
|
||||
# is intentionally the TTS path. This preserves the current TTS execution
|
||||
# model while still making the TTS volume control meaningful on laptops.
|
||||
playback.pcm "tts"
|
||||
capture.pcm "roverd_capture"
|
||||
# Defaults: TTS direct playback + raw capture on the dedicated laptop sound card.
|
||||
pcm.!default {
|
||||
type asym
|
||||
playback.pcm "tts"
|
||||
capture.pcm "rovermic"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type hw
|
||||
card 0
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
ASSET_ROOT = "/opt/roverd/googletts"
|
||||
LIB_PATH = os.path.join(ASSET_ROOT, "libchrometts.so")
|
||||
VOICE_DIR = os.path.join(ASSET_ROOT, "en-us-x-multi-r30")
|
||||
PIPELINE = "pipeline.pb"
|
||||
PLAYBACK_DEVICE = "tts"
|
||||
SAMPLE_RATE = "24000"
|
||||
MAX_TEXT_CHARS = 512
|
||||
|
||||
VOICES = {
|
||||
"sfg": "female",
|
||||
"iob": "female",
|
||||
"iog": "female",
|
||||
"iol": "male",
|
||||
"iom": "male",
|
||||
"tpc": "female",
|
||||
"tpd": "male",
|
||||
"tpf": "female",
|
||||
}
|
||||
DEFAULT_VOICE = "tpf"
|
||||
DEFAULT_PITCH = 1.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
MIN_PITCH = 0.5
|
||||
MAX_PITCH = 2.0
|
||||
MIN_SPEED = 0.5
|
||||
MAX_SPEED = 2.0
|
||||
|
||||
_runtime_handles = []
|
||||
|
||||
|
||||
def load_shared_library(path):
|
||||
mode = ctypes.RTLD_GLOBAL | getattr(os, "RTLD_NOW", 0)
|
||||
return ctypes.CDLL(path, mode=mode)
|
||||
|
||||
|
||||
def preload_runtime_libraries():
|
||||
# Laptop-only workaround: some ChromeOS libchrometts builds reference
|
||||
# compiler helper symbols such as __udivmodti4 without declaring the runtime
|
||||
# library as an ELF dependency. Loading common compiler runtimes globally
|
||||
# first makes those symbols visible before ctypes loads libchrometts.so.
|
||||
for name in ("gcc_s", "atomic", "stdc++", "c++", "c++abi"):
|
||||
lib = ctypes.util.find_library(name)
|
||||
if not lib:
|
||||
continue
|
||||
try:
|
||||
_runtime_handles.append(load_shared_library(lib))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
preload_runtime_libraries()
|
||||
|
||||
|
||||
def varint(value):
|
||||
out = bytearray()
|
||||
while value >= 0x80:
|
||||
out.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
out.append(value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def field_bytes(number, payload):
|
||||
return varint((number << 3) | 2) + varint(len(payload)) + payload
|
||||
|
||||
|
||||
def field_float(number, value):
|
||||
return varint((number << 3) | 5) + struct.pack("<f", float(value))
|
||||
|
||||
|
||||
def build_utterance(text, pitch=1.0, speed=1.0):
|
||||
params = field_float(2, pitch) + field_float(3, speed)
|
||||
msg_b = field_bytes(1, text.encode("utf-8")) + field_bytes(20, params)
|
||||
msg_a = field_bytes(1, msg_b)
|
||||
return field_bytes(1, msg_a)
|
||||
|
||||
|
||||
def build_speaker(name, gender):
|
||||
return field_bytes(1, name.encode("utf-8")) + field_bytes(2, gender.encode("utf-8"))
|
||||
|
||||
|
||||
class ChromeTTS:
|
||||
def __init__(self):
|
||||
self.lib = load_shared_library(LIB_PATH)
|
||||
self.lib.GoogleTtsInit.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
self.lib.GoogleTtsInit.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsInitBuffered.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.GoogleTtsInitBuffered.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.argtypes = []
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.restype = ctypes.c_size_t
|
||||
self.lib.GoogleTtsReadBuffered.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_float),
|
||||
ctypes.POINTER(ctypes.c_size_t),
|
||||
]
|
||||
self.lib.GoogleTtsReadBuffered.restype = ctypes.c_int
|
||||
self.lib.GoogleTtsShutdown.argtypes = []
|
||||
self.lib.GoogleTtsShutdown.restype = None
|
||||
|
||||
voice_dir = os.path.abspath(VOICE_DIR) + os.sep
|
||||
pipeline = os.path.join(voice_dir, PIPELINE)
|
||||
if not self.lib.GoogleTtsInit(pipeline.encode("utf-8"), voice_dir.encode("utf-8")):
|
||||
raise RuntimeError("GoogleTtsInit failed")
|
||||
self.frames = int(self.lib.GoogleTtsGetFramesInAudioBuffer())
|
||||
if self.frames <= 0:
|
||||
raise RuntimeError("invalid Google TTS audio buffer size")
|
||||
self.buffer = (ctypes.c_float * self.frames)()
|
||||
|
||||
def speak_to_aplay(self, text, voice, pitch=DEFAULT_PITCH, speed=DEFAULT_SPEED):
|
||||
voice = voice if voice in VOICES else DEFAULT_VOICE
|
||||
pitch = clamp_float(pitch, MIN_PITCH, MAX_PITCH, DEFAULT_PITCH)
|
||||
speed = clamp_float(speed, MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("text required")
|
||||
text = text[:MAX_TEXT_CHARS]
|
||||
utterance = build_utterance(text, pitch=pitch, speed=speed)
|
||||
speaker = build_speaker(voice, VOICES[voice])
|
||||
if not self.lib.GoogleTtsInitBuffered(utterance, speaker, len(utterance), len(speaker)):
|
||||
raise RuntimeError("GoogleTtsInitBuffered failed")
|
||||
|
||||
player = subprocess.Popen(
|
||||
["aplay", "-q", "-D", PLAYBACK_DEVICE, "-r", SAMPLE_RATE, "-f", "FLOAT_LE", "-c", "1"],
|
||||
stdin=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
frames_written = ctypes.c_size_t(0)
|
||||
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
|
||||
frames = int(frames_written.value)
|
||||
if frames > 0:
|
||||
player.stdin.write(ctypes.string_at(self.buffer, frames * ctypes.sizeof(ctypes.c_float)))
|
||||
player.stdin.close()
|
||||
rc = player.wait()
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"aplay exited with {rc}")
|
||||
finally:
|
||||
if player.poll() is None:
|
||||
player.kill()
|
||||
player.wait()
|
||||
|
||||
def shutdown(self):
|
||||
self.lib.GoogleTtsShutdown()
|
||||
|
||||
|
||||
def respond(payload):
|
||||
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def clamp_float(value, minimum, maximum, fallback):
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if value <= 0:
|
||||
return fallback
|
||||
if value < minimum:
|
||||
return minimum
|
||||
if value > maximum:
|
||||
return maximum
|
||||
return value
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
tts = ChromeTTS()
|
||||
except Exception as exc:
|
||||
respond({"ok": False, "error": str(exc)})
|
||||
return 1
|
||||
|
||||
respond({"ok": True, "ready": True})
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
tts.speak_to_aplay(
|
||||
str(request.get("text") or ""),
|
||||
str(request.get("voice") or DEFAULT_VOICE),
|
||||
request.get("pitch", DEFAULT_PITCH),
|
||||
request.get("speed", DEFAULT_SPEED),
|
||||
)
|
||||
respond({"ok": True})
|
||||
except Exception as exc:
|
||||
respond({"ok": False, "error": str(exc)})
|
||||
finally:
|
||||
tts.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,23 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
install_debian_laptop_deps() {
|
||||
# This profile is deliberately Debian-only. Using apt directly is simpler
|
||||
# than adding a fake cross-distro layer, and it keeps the installed package
|
||||
# set easy to inspect on the actual rover laptop.
|
||||
# The laptop rover is a dedicated appliance, not a normal desktop/laptop audio
|
||||
# install. Keep this package set intentionally close to the Pi profile so the
|
||||
# same roverd TTS/playback/capture code paths are available on both targets.
|
||||
if command -v ffmpeg >/dev/null 2>&1 \
|
||||
&& command -v arecord >/dev/null 2>&1 \
|
||||
&& command -v aplay >/dev/null 2>&1 \
|
||||
&& command -v amixer >/dev/null 2>&1 \
|
||||
&& command -v v4l2-ctl >/dev/null 2>&1 \
|
||||
&& command -v flite >/dev/null 2>&1 \
|
||||
&& command -v espeak >/dev/null 2>&1; then
|
||||
log "Debian laptop media/audio dependencies already installed; skipping apt install"
|
||||
&& command -v espeak >/dev/null 2>&1 \
|
||||
&& command -v python3 >/dev/null 2>&1 \
|
||||
&& command -v curl >/dev/null 2>&1 \
|
||||
&& command -v xz >/dev/null 2>&1 \
|
||||
&& command -v unzip >/dev/null 2>&1 \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libgcc_s\.so\.1' \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libstdc\+\+\.so\.6' \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libc++\.so\.1' \
|
||||
&& ldconfig -p 2>/dev/null | grep -q 'libc++abi\.so\.1'; then
|
||||
log "Debian laptop media/audio/TTS dependencies already installed; skipping apt install"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Installing Debian laptop dependencies (ffmpeg, ALSA tools, V4L2 tools, flite, espeak)..."
|
||||
log "Installing Debian laptop rover dependencies (ffmpeg, ALSA tools, V4L2 tools, flite/espeak, Chrome TTS runtime deps)..."
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ffmpeg alsa-utils v4l-utils ca-certificates flite espeak
|
||||
apt-get install -y --no-install-recommends \
|
||||
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1 libc++abi1 \
|
||||
|| apt-get install -y --no-install-recommends \
|
||||
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1-14 libc++abi1-14
|
||||
}
|
||||
|
||||
disable_debian_laptop_desktop_audio_stack() {
|
||||
# This profile is for a dedicated rover laptop. PipeWire/PulseAudio are good
|
||||
# desktop defaults, but they can grab the hardware device and make the rover's
|
||||
# root/systemd ALSA services fail or route through a moving per-user graph.
|
||||
# Mask them globally and kill already-running instances so ALSA owns the box,
|
||||
# which is the closest behavior to the Pi rover appliance setup.
|
||||
log "Disabling desktop audio daemons for dedicated laptop rover audio"
|
||||
|
||||
local -a user_units=(
|
||||
pipewire.service
|
||||
pipewire.socket
|
||||
pipewire-pulse.service
|
||||
pipewire-pulse.socket
|
||||
wireplumber.service
|
||||
pulseaudio.service
|
||||
pulseaudio.socket
|
||||
)
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --global disable "${user_units[@]}" >/dev/null 2>&1 || true
|
||||
systemctl --global mask "${user_units[@]}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
pkill -x pipewire >/dev/null 2>&1 || true
|
||||
pkill -x pipewire-pulse >/dev/null 2>&1 || true
|
||||
pkill -x wireplumber >/dev/null 2>&1 || true
|
||||
pkill -x pulseaudio >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
install_debian_laptop_audio_support() {
|
||||
@@ -26,17 +66,92 @@ install_debian_laptop_audio_support() {
|
||||
return
|
||||
fi
|
||||
|
||||
# The laptop profile still uses roverd's existing audio contract: TTS plays
|
||||
# to ALSA's default output, horn plays to the named "horn" device, and
|
||||
# forwarded web audio plays to the named "forward" device. Installing one
|
||||
# profile-specific asound.conf gives those paths independent softvol mixer
|
||||
# controls without changing the TTS runtime code.
|
||||
install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf
|
||||
log "Installed Debian laptop ALSA config to /etc/asound.conf"
|
||||
log "ALSA config updated; restarting audio clients or rebooting is recommended before testing laptop audio"
|
||||
log "Installed dedicated Debian laptop ALSA config to /etc/asound.conf"
|
||||
|
||||
install -D -o root -g root -m 0755 pi/bin/chromegtts-daemon-laptop.py /usr/local/bin/chromegtts-daemon
|
||||
log "Installed laptop chromegtts daemon"
|
||||
|
||||
install_google_tts_assets_laptop
|
||||
|
||||
log "ALSA config updated; reboot recommended before testing laptop rover audio"
|
||||
}
|
||||
|
||||
install_google_tts_assets_laptop() {
|
||||
local asset_dir="/opt/roverd/googletts"
|
||||
local voice_dir="${asset_dir}/en-us-x-multi-r30"
|
||||
local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz"
|
||||
local tmp_dir
|
||||
local lib_member=""
|
||||
local member
|
||||
|
||||
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
|
||||
log "Google Chrome TTS assets already installed; skipping download"
|
||||
return
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
log "Downloading Google Chrome TTS assets for Debian laptop profile..."
|
||||
if ! curl -L -o "${tmp_dir}/googletts-26.5.tar.xz" "$dist_url"; then
|
||||
rm -rf "$tmp_dir"
|
||||
log "WARNING: failed to download Google Chrome TTS assets; chromegtts will be unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
local -a candidate_libs=()
|
||||
case "$(uname -m)" in
|
||||
aarch64|arm64)
|
||||
candidate_libs=(libchrometts_arm64.so)
|
||||
;;
|
||||
armv7l|armhf)
|
||||
candidate_libs=(libchrometts_armv7.so)
|
||||
;;
|
||||
x86_64|amd64)
|
||||
candidate_libs=(libchrometts_x86_64.so libchrometts_amd64.so libchrometts_x64.so libchrometts.so)
|
||||
;;
|
||||
i386|i686)
|
||||
candidate_libs=(libchrometts_x86.so libchrometts_i386.so libchrometts.so)
|
||||
;;
|
||||
*)
|
||||
log "WARNING: unsupported Chrome TTS architecture $(uname -m); skipping Google TTS assets"
|
||||
rm -rf "$tmp_dir"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
for member in "${candidate_libs[@]}"; do
|
||||
if tar -tf "${tmp_dir}/googletts-26.5.tar.xz" "$member" >/dev/null 2>&1; then
|
||||
lib_member="$member"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$lib_member" ]]; then
|
||||
log "WARNING: no libchrometts library matching $(uname -m) found in Google TTS archive; chromegtts will be unavailable"
|
||||
rm -rf "$tmp_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! tar -xf "${tmp_dir}/googletts-26.5.tar.xz" -C "$tmp_dir" en-us-x-multi.zvoice "$lib_member"; then
|
||||
rm -rf "$tmp_dir"
|
||||
log "WARNING: failed to unpack Google Chrome TTS assets; chromegtts will be unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
install -d -o root -g root -m 0755 "$asset_dir"
|
||||
install -o root -g root -m 0644 "${tmp_dir}/${lib_member}" "${asset_dir}/libchrometts.so"
|
||||
rm -rf "$voice_dir"
|
||||
install -d -o root -g root -m 0755 "$voice_dir"
|
||||
unzip -q "${tmp_dir}/en-us-x-multi.zvoice" -d "$voice_dir"
|
||||
chown -R root:root "$asset_dir"
|
||||
find "$asset_dir" -type d -exec chmod 0755 {} +
|
||||
find "$asset_dir" -type f -exec chmod 0644 {} +
|
||||
rm -rf "$tmp_dir"
|
||||
log "Installed Google Chrome TTS assets to $asset_dir using $lib_member"
|
||||
}
|
||||
|
||||
install_debian_laptop_profile() {
|
||||
install_debian_laptop_deps
|
||||
disable_debian_laptop_desktop_audio_stack
|
||||
install_debian_laptop_audio_support
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user