diff --git a/Content.Server/_White/VoiceRecorder/VoiceRecorderComponent.cs b/Content.Server/_White/VoiceRecorder/VoiceRecorderComponent.cs
new file mode 100644
index 0000000000..de038e7810
--- /dev/null
+++ b/Content.Server/_White/VoiceRecorder/VoiceRecorderComponent.cs
@@ -0,0 +1,68 @@
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Shared._White.VoiceRecorder;
+
+///
+/// This is used for...
+///
+[RegisterComponent]
+public sealed partial class VoiceRecorderComponent : Component
+{
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("enabled")]
+ public bool Enabled { get; set; } = true;
+
+ [DataField("blacklist")]
+ public EntityWhitelist Blacklist { get; private set; } = new();
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("range")]
+ public int Range { get; private set; } = 10;
+
+ [DataField("listening")]
+ public bool Listening { get; set; } = false;
+
+ ///
+ /// The sound that's played when the scanner prints off a report.
+ ///
+ [DataField("soundPrint")]
+ public SoundSpecifier SoundPrint = new SoundPathSpecifier("/Audio/Machines/short_print_and_rip.ogg");
+
+ [DataField("soundEndOfRecording")]
+ public SoundSpecifier SoundEndOfRecording = new SoundPathSpecifier("/Audio/Machines/id_insert.ogg");
+
+ [DataField("soundStartOfRecording")]
+ public SoundSpecifier SoundStartOfRecording = new SoundPathSpecifier("/Audio/Weapons/Guns/MagIn/pistol_magin.ogg");
+
+ ///
+ /// What the machine will print
+ ///
+ [DataField("machineOutput", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string MachineOutput = "PaperOffice";
+
+ [DataField("recordings")]
+ public List Recordings = new();
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("maximumEntries")]
+ public int MaximumEntries = 100;
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("customTitle")]
+ public string CustomTitle = "";
+
+ ///
+ /// When will the recorder be ready to print again?
+ ///
+ [ViewVariables(VVAccess.ReadOnly)]
+ public TimeSpan PrintReadyAt = TimeSpan.Zero;
+
+ ///
+ /// How often can the recorder print out reports?
+ ///
+ [DataField("printCooldown")]
+ public TimeSpan PrintCooldown = TimeSpan.FromSeconds(5);
+}
diff --git a/Content.Server/_White/VoiceRecorder/VoiceRecorderSystem.cs b/Content.Server/_White/VoiceRecorder/VoiceRecorderSystem.cs
new file mode 100644
index 0000000000..87c42b7ea8
--- /dev/null
+++ b/Content.Server/_White/VoiceRecorder/VoiceRecorderSystem.cs
@@ -0,0 +1,178 @@
+using System.Text;
+using Content.Server.Paper;
+using Content.Server.Popups;
+using Content.Server.Speech;
+using Content.Server.Speech.Components;
+using Content.Shared._White.VoiceRecorder;
+using Content.Shared.Examine;
+using Content.Shared.GameTicking;
+using Content.Shared.Hands.EntitySystems;
+using Content.Shared.Interaction;
+using Content.Shared.Item;
+using Content.Shared.Paper;
+using Content.Shared.Toggleable;
+using Content.Shared.Verbs;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Server._White.VoiceRecorder;
+
+///
+/// This handles the voice recorder all itself.
+///
+public sealed class VoiceRecorderSystem : EntitySystem
+{
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly SharedHandsSystem _handsSystem = default!;
+ [Dependency] private readonly PaperSystem _paperSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly SharedItemSystem _item = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly SharedGameTicker _gameTicker = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnActivate);
+ SubscribeLocalEvent(OnExamine);
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(SaveEntityMessage);
+ SubscribeLocalEvent(CanListen);
+ SubscribeLocalEvent>(AddVerbs);
+ }
+
+ public void OnInit(EntityUid uid, VoiceRecorderComponent component, ComponentInit args)
+ {
+ if (!TryComp(uid, out var listener))
+ {
+ RemComp(uid);
+ component.Listening = false;
+ return;
+ }
+ ToggleListening(uid, component, component.Listening);
+
+ }
+
+ public void OnExamine(EntityUid uid, VoiceRecorderComponent component, ExaminedEvent args)
+ {
+ if (args.IsInDetailsRange)
+ {
+ var message = $"{Loc.GetString("voice-recorder-state")} {Loc.GetString( component.Listening ? "voice-recorder-state-on" : "voice-recorder-state-off")}";
+ args.PushMarkup(message);
+ }
+ }
+
+ public void OnActivate(EntityUid uid, VoiceRecorderComponent component, ActivateInWorldEvent args)
+ {
+ component.Listening = !component.Listening;
+ ToggleListening(uid, component, component.Listening);
+ var message = Loc.GetString(component.Listening ? "voice-recorder-on" : "voice-recorder-off");
+ _popup.PopupEntity(message, args.User, args.User);
+ args.Handled = true;
+ }
+
+ private void AddVerbs(EntityUid uid, VoiceRecorderComponent component, GetVerbsEvent args)
+ {
+ if (!args.CanAccess || !component.Enabled || component.Listening || component.Recordings.Count == 0)
+ return;
+ AlternativeVerb verb = new();
+ verb.Text = Loc.GetString("voice-recorder-print");
+ verb.Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/eject.svg.192dpi.png"));
+ verb.Act = () => OnPrint(uid, component, component.Recordings.ToArray(), args.User);
+ args.Verbs.Add(verb);
+ }
+
+ public void ToggleListening(EntityUid uid, VoiceRecorderComponent component, bool listening)
+ {
+ component.Listening = listening;
+ if (listening)
+ {
+ component.Recordings.Clear();
+ EnsureComp(uid).Range = component.Range;
+ _audioSystem.PlayPvs(component.SoundStartOfRecording, uid,
+ AudioParams.Default
+ .WithVariation(0.25f)
+ .WithVolume(0.3f)
+ .WithRolloffFactor(2.8f)
+ .WithMaxDistance(1.5f));
+ }
+ else
+ {
+ RemComp(uid);
+ _audioSystem.PlayPvs(component.SoundEndOfRecording, uid,
+ AudioParams.Default
+ .WithVariation(0.25f)
+ .WithVolume(0.3f)
+ .WithRolloffFactor(2.8f)
+ .WithMaxDistance(1.5f));
+ }
+ if (TryComp(uid, out var appearance) &&
+ TryComp(uid, out var item))
+ {
+ _item.SetHeldPrefix(uid, listening ? "on" : "off", false, item);
+ _appearance.SetData(uid, ToggleVisuals.Toggled, listening, appearance);
+ }
+ }
+
+ public void CanListen(EntityUid uid, VoiceRecorderComponent component, ListenAttemptEvent args)
+ {
+ if (component.Blacklist.IsValid(args.Source))
+ args.Cancel();
+ }
+
+ public void SaveEntityMessage(EntityUid uid, VoiceRecorderComponent component, ListenEvent args)
+ {
+ if (!component.Listening)
+ return;
+
+ var message = $"{_gameTiming.CurTime.Subtract(_gameTicker.RoundStartTimeSpan).ToString("hh\\:mm\\:ss")} {Name(args.Source)}: {args.Message}";
+ component.Recordings.Add(message);
+
+ if (component.Recordings.Count > (component.MaximumEntries - 1))
+ {
+ ToggleListening(uid, component, false);
+ }
+ }
+
+ public void OnPrint(EntityUid uid, VoiceRecorderComponent component, string[] messages, EntityUid user)
+ {
+ if (_gameTiming.CurTime < component.PrintReadyAt)
+ {
+ _popup.PopupEntity(Loc.GetString("forensic-scanner-printer-not-ready"), uid, user);
+ return;
+ }
+ // TEXT TO PRINT
+ var text = new StringBuilder();
+ text.AppendLine(component.CustomTitle == "" ? Loc.GetString("voice-recorder-title") : component.CustomTitle);
+ text.AppendLine("");
+ text.AppendLine(Loc.GetString("voice-recorder-start"));
+ foreach (var message in messages)
+ {
+ text.AppendLine(message);
+ }
+ text.AppendLine(Loc.GetString("voice-recorder-end"));
+
+ var printed = EntityManager.SpawnEntity(component.MachineOutput, Transform(uid).Coordinates);
+ _paperSystem.SetContent(printed, text.ToString());
+ var stamp = new StampDisplayInfo();
+ stamp.StampedName = Loc.GetString("voice-recorder-stamp");
+ stamp.StampedColor = new Color(47, 47, 56);
+ _paperSystem.TryStamp(printed, stamp, "paper_stamp-transcript", null);
+ _handsSystem.PickupOrDrop(user, printed, checkActionBlocker: false);
+ _audioSystem.PlayPvs(component.SoundPrint, uid,
+ AudioParams.Default
+ .WithVariation(0.25f)
+ .WithVolume(3f)
+ .WithRolloffFactor(2.8f)
+ .WithMaxDistance(4.5f));
+ _metaData.SetEntityName(printed, Loc.GetString("voice-recorder-paper-name"));
+ _metaData.SetEntityDescription(printed, Loc.GetString("voice-recorder-paper-desc"));
+ ToggleListening(uid, component, false);
+ component.PrintReadyAt = _gameTiming.CurTime + component.PrintCooldown;
+ }
+}
diff --git a/Resources/Locale/en-US/white/voice-recorder.ftl b/Resources/Locale/en-US/white/voice-recorder.ftl
new file mode 100644
index 0000000000..61bf498023
--- /dev/null
+++ b/Resources/Locale/en-US/white/voice-recorder.ftl
@@ -0,0 +1,15 @@
+voice-recorder-on = recording now
+voice-recorder-off = recording stopped
+voice-recorder-print = Print recording
+voice-recorder-stamp = TRANSCRIPT
+voice-recorder-title = NANOTRASEN BLCK-M VOICE RECORDER TRANSCRIPT
+voice-recorder-start = *start of recording*
+voice-recorder-end = *end of recording*
+voice-recorder-paper-name = recorder transcript
+voice-recorder-paper-desc = BLCK-M voice recorder transcript
+voice-recorder-state = Voice recorder
+voice-recorder-state-on = is recording
+voice-recorder-state-off = is off
+
+ent-CrateSecurityVoiceRecorder = voice recorder crate
+ .desc = Contains 3 voice recorders to ensure that no evidence will be lost. Does not require any access to open.
diff --git a/Resources/Locale/ru-RU/white/voice-recorder.ftl b/Resources/Locale/ru-RU/white/voice-recorder.ftl
new file mode 100644
index 0000000000..ebea1af1cb
--- /dev/null
+++ b/Resources/Locale/ru-RU/white/voice-recorder.ftl
@@ -0,0 +1,17 @@
+voice-recorder-on = запись включена
+voice-recorder-off = запись остановлена
+voice-recorder-print = Распечатать
+voice-recorder-stamp = СТЕНОГРАММА
+voice-recorder-title = NANOTRASEN BLCK-M VOICE RECORDER TRANSCRIPT
+voice-recorder-start = *начало записи*
+voice-recorder-end = *конец записи*
+voice-recorder-paper-name = стенограмма
+voice-recorder-paper-desc = копия стенограммы записанной на диктофон типа BLCK-M
+voice-recorder-state = Диктофон
+voice-recorder-state-on = ведёт запись
+voice-recorder-state-off = выключен
+
+ent-VoiceRecorder = диктофон
+ .desc = Диктофон типа BLCK-M. Имеет втроенный принтер для печати стенограмм. Каждая новая запись стирает предыдущую.
+ent-CrateSecurityVoiceRecorder = ящик с диктофонами
+ .desc = Ящик с диктофонами типа BLCK-M. Содержит 3 единицы. Не трубует дополнительных доступов.
diff --git a/Resources/Prototypes/Catalog/Cargo/cargo_security.yml b/Resources/Prototypes/Catalog/Cargo/cargo_security.yml
index f3121395d9..302805c392 100644
--- a/Resources/Prototypes/Catalog/Cargo/cargo_security.yml
+++ b/Resources/Prototypes/Catalog/Cargo/cargo_security.yml
@@ -87,3 +87,13 @@
cost: 2000
category: Security
group: market
+
+- type: cargoProduct
+ id: SecurityVoiceRecorder
+ icon:
+ sprite: White/VoiceRecorder/voicerecorder.rsi
+ state: icon
+ product: CrateSecurityVoiceRecorder
+ cost: 1000
+ category: Security
+ group: market
diff --git a/Resources/Prototypes/Catalog/Fills/Crates/security.yml b/Resources/Prototypes/Catalog/Fills/Crates/security.yml
index 950b11f1f9..6784ded19d 100644
--- a/Resources/Prototypes/Catalog/Fills/Crates/security.yml
+++ b/Resources/Prototypes/Catalog/Fills/Crates/security.yml
@@ -123,3 +123,12 @@
contents:
- id: BoxBodyCamera
amount: 2
+
+- type: entity
+ id: CrateSecurityVoiceRecorder
+ parent: CrateGenericSteel
+ components:
+ - type: StorageFill
+ contents:
+ - id: VoiceRecorder
+ amount: 4
diff --git a/Resources/Prototypes/Entities/White/voice_recorder.yml b/Resources/Prototypes/Entities/White/voice_recorder.yml
new file mode 100644
index 0000000000..53b1e02c81
--- /dev/null
+++ b/Resources/Prototypes/Entities/White/voice_recorder.yml
@@ -0,0 +1,29 @@
+- type: entity
+ parent: BaseItem
+ id: VoiceRecorder
+ name: voice recorder
+ description: BLCK-M type voice recorder. Has built-in printer for printing transcripts. Each new record erases previous one.
+ components:
+ - type: Sprite
+ sprite: White/VoiceRecorder/voicerecorder.rsi
+ layers:
+ - state: icon-on
+ - type: GenericVisualizer
+ visuals:
+ enum.ToggleVisuals.Toggled:
+ enum.ToggleVisuals.Layer:
+ True: { state: icon-on }
+ False: { state: icon }
+ - type: Item
+ heldPrefix: off
+ sprite: White/VoiceRecorder/voicerecorder.rsi
+ - type: Appearance
+ - type: VoiceRecorder
+ blacklist:
+ components:
+ - SurveillanceCamera
+ - SurveillanceCameraMonitor
+ - RadioSpeaker
+ range: 5
+ - type: ActiveListener
+ range: 5
diff --git a/Resources/Textures/Objects/Misc/bureaucracy.rsi/meta.json b/Resources/Textures/Objects/Misc/bureaucracy.rsi/meta.json
index cfa5fb3095..6ceed3d96f 100644
--- a/Resources/Textures/Objects/Misc/bureaucracy.rsi/meta.json
+++ b/Resources/Textures/Objects/Misc/bureaucracy.rsi/meta.json
@@ -256,6 +256,9 @@
},
{
"name": "paper_stamp-geraldiy"
- }
+ },
+ {
+ "name": "paper_stamp-transcript"
+ }
]
}
diff --git a/Resources/Textures/Objects/Misc/bureaucracy.rsi/paper_stamp-transcript.png b/Resources/Textures/Objects/Misc/bureaucracy.rsi/paper_stamp-transcript.png
new file mode 100644
index 0000000000..f721327a2e
Binary files /dev/null and b/Resources/Textures/Objects/Misc/bureaucracy.rsi/paper_stamp-transcript.png differ
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/icon-on.png b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/icon-on.png
new file mode 100644
index 0000000000..7a0bcd80c4
Binary files /dev/null and b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/icon-on.png differ
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/icon.png b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/icon.png
new file mode 100644
index 0000000000..3c5b4ff4b7
Binary files /dev/null and b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/icon.png differ
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/meta.json b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/meta.json
new file mode 100644
index 0000000000..8212493b99
--- /dev/null
+++ b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/meta.json
@@ -0,0 +1,97 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "by Gargrarien",
+ "size": {"x": 32, "y": 32},
+ "states":
+ [
+ {
+ "name": "icon-on",
+ "directions": 1,
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "icon",
+ "directions": 1
+ },
+ {
+ "name": "off-inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "off-inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "on-inhand-left",
+ "directions": 4,
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ },
+ {
+ "name": "on-inhand-right",
+ "directions": 4,
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ],
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/off-inhand-left.png b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/off-inhand-left.png
new file mode 100644
index 0000000000..32d397683f
Binary files /dev/null and b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/off-inhand-left.png differ
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/off-inhand-right.png b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/off-inhand-right.png
new file mode 100644
index 0000000000..140c8115b3
Binary files /dev/null and b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/off-inhand-right.png differ
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/on-inhand-left.png b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/on-inhand-left.png
new file mode 100644
index 0000000000..e234eb1c87
Binary files /dev/null and b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/on-inhand-left.png differ
diff --git a/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/on-inhand-right.png b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/on-inhand-right.png
new file mode 100644
index 0000000000..65fe926d3a
Binary files /dev/null and b/Resources/Textures/White/VoiceRecorder/voicerecorder.rsi/on-inhand-right.png differ