mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 11:46:41 +01:00
refactor: atomic timebase
This commit is contained in:
parent
87c5e47b43
commit
f77c84a99c
5 changed files with 116 additions and 99 deletions
|
|
@ -8,11 +8,11 @@ pub struct Sequencer {
|
|||
/// JACK transport handle.
|
||||
transport: ::jack::Transport,
|
||||
/// Holds info about tempo
|
||||
timebase: Arc<Mutex<Timebase>>,
|
||||
timebase: Arc<Timebase>,
|
||||
/// Sequencer resolution, e.g. 16 steps per beat.
|
||||
resolution: u64,
|
||||
resolution: usize,
|
||||
/// Steps in sequence, e.g. 64 16ths = 4 beat loop.
|
||||
steps: u64,
|
||||
steps: usize,
|
||||
|
||||
/// JACK MIDI input port that will be created.
|
||||
input_port: Port<MidiIn>,
|
||||
|
|
@ -56,7 +56,7 @@ enum SequencerView {
|
|||
}
|
||||
|
||||
impl Sequencer {
|
||||
pub fn new (name: &str, timebase: &Arc<Mutex<Timebase>>) -> Usually<DynamicDevice<Self>> {
|
||||
pub fn new (name: &str, timebase: &Arc<Timebase>) -> Usually<DynamicDevice<Self>> {
|
||||
let (client, _status) = Client::new(name, ClientOptions::NO_START_SERVER)?;
|
||||
DynamicDevice::new(render, handle, process, Self {
|
||||
name: name.into(),
|
||||
|
|
@ -96,10 +96,10 @@ fn process_in (s: &mut Sequencer, scope: &ProcessScope) {
|
|||
return
|
||||
}
|
||||
let pos = s.transport.query().unwrap().pos;
|
||||
let usecs = Frame(pos.frame()).to_usec(&s.rate).0;
|
||||
let steps = usecs / s.tempo.usec_per_step(s.resolution as u64).0;
|
||||
let usecs = s.timebase.frame_to_usec(pos.frame() as usize);
|
||||
let steps = usecs / s.timebase.usec_per_step(s.resolution as usize);
|
||||
let step = steps % s.steps;
|
||||
let tick = step * s.ticks_per_beat / s.resolution;
|
||||
let tick = step * s.timebase.ppq() / s.resolution;
|
||||
|
||||
for event in s.input_port.iter(scope) {
|
||||
match midly::live::LiveEvent::parse(event.bytes).unwrap() {
|
||||
|
|
@ -142,8 +142,12 @@ fn process_out (s: &mut Sequencer, scope: &ProcessScope) {
|
|||
if transport.state != ::jack::TransportState::Rolling {
|
||||
return
|
||||
}
|
||||
let frame = transport.pos.frame() as u64;
|
||||
let ticks = s.frames_to_ticks(frame, frame + scope.n_frames() as u64);
|
||||
let frame = transport.pos.frame() as usize;
|
||||
let ticks = s.timebase.frames_to_ticks(
|
||||
frame,
|
||||
frame + scope.n_frames() as usize,
|
||||
s.timebase.fpb() as usize * s.steps / s.resolution
|
||||
);
|
||||
let mut writer = s.output_port.writer(scope);
|
||||
for (time, tick) in ticks.iter() {
|
||||
if let Some(events) = s.sequence.get(&(*tick as u32)) {
|
||||
|
|
@ -170,8 +174,8 @@ fn render (s: &Sequencer, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
|||
let pos = s.transport.query().unwrap().pos;
|
||||
let frame = pos.frame();
|
||||
let rate = pos.frame_rate().unwrap();
|
||||
let usecs = Frame(frame).to_usec(&Hz(rate)).0;
|
||||
let usec_per_step = s.tempo.usec_per_step(s.resolution as u64).0;
|
||||
let usecs = s.timebase.frame_to_usec(frame as usize);
|
||||
let usec_per_step = s.timebase.usec_per_step(s.resolution as usize);
|
||||
let steps = usecs / usec_per_step;
|
||||
let header = draw_header(s, buf, area, steps)?;
|
||||
let piano = match s.mode {
|
||||
|
|
@ -200,7 +204,7 @@ fn render (s: &Sequencer, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
|||
}))
|
||||
}
|
||||
|
||||
fn draw_header (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usually<Rect> {
|
||||
fn draw_header (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: usize) -> Usually<Rect> {
|
||||
let rep = beat / s.steps;
|
||||
let step = beat % s.steps;
|
||||
let reps = s.steps / s.resolution;
|
||||
|
|
@ -241,11 +245,12 @@ const KEYS_VERTICAL: [&'static str; 6] = [
|
|||
"▀", "▀", "▀", "█", "▄", "▄",
|
||||
];
|
||||
|
||||
fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usually<Rect> {
|
||||
fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: usize) -> Usually<Rect> {
|
||||
let ppq = s.timebase.ppq() as u32;
|
||||
let Rect { x, y, .. } = area;
|
||||
let (time0, time1) = s.time_axis;
|
||||
let (note0, note1) = s.note_axis;
|
||||
let bw = Style::default().dim();
|
||||
let _bw = Style::default().dim();
|
||||
let bg = Style::default().on_black();
|
||||
for key in note0..note1 {
|
||||
let x = x + 5 + key - note0;
|
||||
|
|
@ -257,9 +262,9 @@ fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usu
|
|||
let mut is_on = s.notes_on[key as usize];
|
||||
let step = beat % s.steps;
|
||||
let (a, b, c) = (
|
||||
(step + 0) as u32 * s.ticks_per_beat as u32 / s.resolution as u32,
|
||||
(step + 1) as u32 * s.ticks_per_beat as u32 / s.resolution as u32,
|
||||
(step + 2) as u32 * s.ticks_per_beat as u32 / s.resolution as u32,
|
||||
(step + 0) as u32 * ppq / s.resolution as u32,
|
||||
(step + 1) as u32 * ppq / s.resolution as u32,
|
||||
(step + 2) as u32 * ppq / s.resolution as u32,
|
||||
);
|
||||
let key = ::midly::num::u7::from(key as u8);
|
||||
is_on = is_on || contains_note_on(&s.sequence, key, a, b);
|
||||
|
|
@ -271,7 +276,7 @@ fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usu
|
|||
}
|
||||
for step in time0..time1 {
|
||||
let y = y - time0 + step / 2;
|
||||
let step = step as u64;
|
||||
let step = step as usize;
|
||||
//buf.set_string(x + 5, y, &" ".repeat(32.max(note1-note0)as usize), bg);
|
||||
if step % s.resolution == 0 {
|
||||
buf.set_string(x + 2, y, &format!("{:2} ", step + 1), Style::default());
|
||||
|
|
@ -280,9 +285,9 @@ fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usu
|
|||
let key = ::midly::num::u7::from_int_lossy(k as u8);
|
||||
if step % 2 == 0 {
|
||||
let (a, b, c) = (
|
||||
(step + 0) as u32 * s.ticks_per_beat as u32 / s.resolution as u32,
|
||||
(step + 1) as u32 * s.ticks_per_beat as u32 / s.resolution as u32,
|
||||
(step + 2) as u32 * s.ticks_per_beat as u32 / s.resolution as u32,
|
||||
(step + 0) as u32 * ppq / s.resolution as u32,
|
||||
(step + 1) as u32 * ppq / s.resolution as u32,
|
||||
(step + 2) as u32 * ppq / s.resolution as u32,
|
||||
);
|
||||
let (character, style) = match (
|
||||
contains_note_on(&s.sequence, key, a, b),
|
||||
|
|
@ -296,10 +301,10 @@ fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usu
|
|||
buf.set_string(x + 5 + k - note0, y, character, style);
|
||||
}
|
||||
}
|
||||
if beat % s.steps == step as u64 {
|
||||
if beat % s.steps == step as usize {
|
||||
buf.set_string(x + 4, y, if beat % 2 == 0 { "▀" } else { "▄" }, Style::default().yellow());
|
||||
for key in note0..note1 {
|
||||
let color = if s.notes_on[key as usize] {
|
||||
let _color = if s.notes_on[key as usize] {
|
||||
Style::default().red()
|
||||
} else {
|
||||
KEY_HORIZONTAL_STYLE[key as usize % 12]
|
||||
|
|
@ -327,6 +332,23 @@ fn draw_vertical (s: &Sequencer, buf: &mut Buffer, area: Rect, beat: u64) -> Usu
|
|||
Ok(Rect { x, y, width: area.width, height })
|
||||
}
|
||||
|
||||
fn contains_note_on (sequence: &Sequence, k: ::midly::num::u7, start: u32, end: u32) -> bool {
|
||||
for (_, (_, events)) in sequence.range(start..end).enumerate() {
|
||||
for event in events.iter() {
|
||||
match event {
|
||||
::midly::MidiMessage::NoteOn {key,..} => {
|
||||
if *key == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
const KEY_WHITE: Style = Style {
|
||||
fg: Some(Color::Gray),
|
||||
bg: None,
|
||||
|
|
@ -348,26 +370,6 @@ const KEY_HORIZONTAL_STYLE: [Style;12] = [
|
|||
KEY_WHITE, KEY_BLACK, KEY_WHITE, KEY_BLACK, KEY_WHITE, KEY_BLACK, KEY_WHITE,
|
||||
];
|
||||
|
||||
fn contains_note_on (sequence: &Sequence, k: ::midly::num::u7, start: u32, end: u32) -> bool {
|
||||
for (_, (_, events)) in sequence.range(start..end).enumerate() {
|
||||
for event in events.iter() {
|
||||
match event {
|
||||
::midly::MidiMessage::NoteOn {key,..} => {
|
||||
if *key == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const KEYS_VERTICAL: [&'static str; 6] = [
|
||||
"▀", "▀", "▀", "█", "▄", "▄",
|
||||
];
|
||||
|
||||
fn draw_horizontal (s: &Sequencer, buf: &mut Buffer, area: Rect) -> Usually<Rect> {
|
||||
let Rect { x, y, .. } = area;
|
||||
let (time0, time1) = s.time_axis;
|
||||
|
|
@ -385,9 +387,9 @@ fn draw_horizontal (s: &Sequencer, buf: &mut Buffer, area: Rect) -> Usually<Rect
|
|||
}
|
||||
}
|
||||
for step in time0..time1 {
|
||||
let time_start = step as u32 * s.ticks_per_beat as u32;
|
||||
let time_end = (step + 1) as u32 * s.ticks_per_beat as u32;
|
||||
for (i, (t, events)) in s.sequence.range(time_start..time_end).enumerate() {
|
||||
let time_start = step as u32 * s.timebase.ppq() as u32;
|
||||
let time_end = (step + 1) as u32 * s.timebase.ppq() as u32;
|
||||
for (_, (_, events)) in s.sequence.range(time_start..time_end).enumerate() {
|
||||
if events.len() > 0 {
|
||||
buf.set_string(x + 5 + step as u16, y, "█", bw);
|
||||
}
|
||||
|
|
@ -446,12 +448,12 @@ pub const COMMANDS: &'static [KeyBinding<Sequencer>] = keymap!(Sequencer {
|
|||
fn nop (_: &mut Sequencer) {
|
||||
}
|
||||
fn note_add (s: &mut Sequencer) {
|
||||
let pos = s.transport.query().unwrap().pos;
|
||||
let usecs = Frame(pos.frame()).to_usec(&s.rate).0;
|
||||
let steps = usecs / s.tempo.usec_per_step(s.resolution as u64).0;
|
||||
let step = (s.time_axis.0 + s.time_cursor) as u32;
|
||||
let start = (step as u64 * s.ticks_per_beat / s.resolution) as u32;
|
||||
let end = ((step + 1) as u64 * s.ticks_per_beat / s.resolution) as u32;
|
||||
let pos = s.transport.query().unwrap().pos;
|
||||
let usecs = s.timebase.frame_to_usec(pos.frame() as usize);
|
||||
let steps = usecs / s.timebase.usec_per_step(s.resolution as usize);
|
||||
let step = (s.time_axis.0 + s.time_cursor) as u32;
|
||||
let start = (step as usize * s.timebase.ppq() / s.resolution) as u32;
|
||||
let end = ((step + 1) as usize * s.timebase.ppq() / s.resolution) as u32;
|
||||
let key = ::midly::num::u7::from_int_lossy((s.note_cursor + s.note_axis.0) as u8);
|
||||
let note_on = ::midly::MidiMessage::NoteOn { key, vel: 100.into() };
|
||||
let note_off = ::midly::MidiMessage::NoteOff { key, vel: 100.into() };
|
||||
|
|
@ -571,7 +573,7 @@ fn quantize_prev (s: &mut Sequencer) {
|
|||
println!("T/L = {:.03}", s.tpl());
|
||||
let fpt = s.fpt();
|
||||
let frames_per_chunk = 240;
|
||||
let chunk = |chunk: u64| s.frames_to_ticks(
|
||||
let chunk = |chunk: usize| s.frames_to_ticks(
|
||||
chunk * frames_per_chunk,
|
||||
(chunk + 1) * frames_per_chunk
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ use crate::prelude::*;
|
|||
pub struct Transport {
|
||||
name: String,
|
||||
/// Holds info about tempo
|
||||
timebase: Arc<Mutex<Timebase>>,
|
||||
timebase: Arc<Timebase>,
|
||||
|
||||
transport: ::jack::Transport,
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
|
|
@ -12,11 +14,17 @@ impl Transport {
|
|||
let transport = client.transport();
|
||||
DynamicDevice::new(render, handle, process, Self {
|
||||
name: name.into(),
|
||||
timebase: Timebase {
|
||||
rate: transport.query()?.pos.frame_rate(),
|
||||
tempo: 113000,
|
||||
ppq: 96,
|
||||
},
|
||||
timebase: Arc::new(Timebase {
|
||||
rate: AtomicUsize::new(
|
||||
transport.query()?.pos.frame_rate().map(|x|x as usize).unwrap_or(0)
|
||||
),
|
||||
tempo: AtomicUsize::new(
|
||||
113000
|
||||
),
|
||||
ppq: AtomicUsize::new(
|
||||
96
|
||||
),
|
||||
}),
|
||||
transport
|
||||
}).activate(client)
|
||||
}
|
||||
|
|
@ -39,6 +47,10 @@ impl Transport {
|
|||
pub fn stop (&mut self) -> Result<(), Box<dyn Error>> {
|
||||
Ok(self.transport.stop()?)
|
||||
}
|
||||
|
||||
pub fn timebase (&self) -> Arc<Timebase> {
|
||||
self.timebase.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process (_: &mut Transport, _: &Client, _: &ProcessScope) -> Control {
|
||||
|
|
@ -63,8 +75,8 @@ pub fn render (state: &Transport, buf: &mut Buffer, mut area: Rect)
|
|||
"0.0.00",
|
||||
"0:00.000",
|
||||
&format!("BPM {:03}.{:03}",
|
||||
state.bpm as u64,
|
||||
((state.bpm % 1.0) * 1000.0) as u64
|
||||
state.timebase.tempo() / 1000,
|
||||
state.timebase.tempo() % 1000,
|
||||
)
|
||||
].iter() {
|
||||
buf.set_string(area.x + x, area.y + 2, button, label);
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ fn main () -> Result<(), Box<dyn Error>> {
|
|||
let xdg = microxdg::XdgApp::new("dawdle")?;
|
||||
crate::config::create_dirs(&xdg)?;
|
||||
//crate::device::run(Sequencer::new("Rhythm#000")?)
|
||||
const transport = Transport::new("Transport")?;
|
||||
const timebase = transport.timebase.clone();
|
||||
let transport = Transport::new("Transport")?;
|
||||
let timebase = transport.state.lock().unwrap().timebase();
|
||||
crate::device::run(Rows::new(true, vec![
|
||||
Box::new(transport),
|
||||
Box::new(Columns::new(true, vec![
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ pub use std::time::Duration;
|
|||
pub use std::sync::{
|
||||
Arc,
|
||||
Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{
|
||||
Ordering,
|
||||
AtomicBool,
|
||||
AtomicUsize
|
||||
},
|
||||
mpsc::{self, channel, Sender, Receiver}
|
||||
};
|
||||
|
||||
|
|
|
|||
69
src/time.rs
69
src/time.rs
|
|
@ -1,10 +1,12 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
pub struct Timebase {
|
||||
/// Frames per second
|
||||
pub rate: Option<usize>,
|
||||
pub rate: AtomicUsize,
|
||||
/// Beats per minute
|
||||
pub tempo: Option<usize>,
|
||||
pub tempo: AtomicUsize,
|
||||
/// Ticks per beat
|
||||
pub ppq: usize,
|
||||
pub ppq: AtomicUsize,
|
||||
}
|
||||
|
||||
enum QuantizeMode {
|
||||
|
|
@ -24,22 +26,32 @@ struct Loop<T> {
|
|||
/// NoteDuration in musical terms. Has definite usec value
|
||||
/// for given bpm and sample rate.
|
||||
pub enum NoteDuration {
|
||||
Nth(u16, u16),
|
||||
Nth(usize, usize),
|
||||
Dotted(Box<Self>),
|
||||
Tuplet(u16, Box<Self>),
|
||||
Tuplet(usize, Box<Self>),
|
||||
}
|
||||
|
||||
impl Timebase {
|
||||
pub fn rate (&self) -> usize {
|
||||
self.rate.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn tempo (&self) -> usize {
|
||||
self.tempo.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn ppq (&self) -> usize {
|
||||
self.ppq.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Beats per second
|
||||
#[inline] fn bps (&self) -> f64 {
|
||||
self.tempo.0 as f64 / 60000.0
|
||||
self.tempo() as f64 / 60000.0
|
||||
}
|
||||
/// Frames per second
|
||||
#[inline] fn fps (&self) -> u64 {
|
||||
self.rate.0 as u64
|
||||
#[inline] fn fps (&self) -> usize {
|
||||
self.rate()
|
||||
}
|
||||
/// Frames per beat
|
||||
#[inline] fn fpb (&self) -> f64 {
|
||||
#[inline] pub fn fpb (&self) -> f64 {
|
||||
self.fps() as f64 / self.bps()
|
||||
}
|
||||
/// Frames per tick FIXME double times
|
||||
|
|
@ -47,19 +59,18 @@ impl Timebase {
|
|||
self.fps() as f64 / self.tps()
|
||||
}
|
||||
/// Frames per loop
|
||||
#[inline] fn fpl (&self) -> f64 {
|
||||
self.fpb() * self.steps as f64 / self.steps_per_beat as f64
|
||||
#[inline] fn fpl (&self, steps: f64, steps_per_beat: f64) -> f64 {
|
||||
self.fpb() * steps / steps_per_beat
|
||||
}
|
||||
/// Ticks per beat
|
||||
#[inline] fn tpb (&self) -> f64 {
|
||||
self.ticks_per_beat as f64
|
||||
self.ppq.load(Ordering::Relaxed) as f64
|
||||
}
|
||||
/// Ticks per second
|
||||
#[inline] fn tps (&self) -> f64 {
|
||||
self.bps() * self.tpb()
|
||||
}
|
||||
fn frames_to_ticks (&self, start: u64, end: u64) -> Vec<(u64, u64)> {
|
||||
let fpl = self.fpl() as u64;
|
||||
pub fn frames_to_ticks (&self, start: usize, end: usize, fpl: usize) -> Vec<(usize, usize)> {
|
||||
let start_frame = start % fpl;
|
||||
let end_frame = end % fpl;
|
||||
let fpt = self.fpt();
|
||||
|
|
@ -69,7 +80,7 @@ impl Timebase {
|
|||
let last_jitter = (frame - 1.0).max(0.0) % fpt;
|
||||
let next_jitter = frame + 1.0 % fpt;
|
||||
if jitter <= last_jitter && jitter <= next_jitter {
|
||||
ticks.push((frame as u64 % (end-start), (frame / fpt) as u64));
|
||||
ticks.push((frame as usize % (end-start), (frame / fpt) as usize));
|
||||
};
|
||||
};
|
||||
if start_frame < end_frame {
|
||||
|
|
@ -99,11 +110,11 @@ impl Timebase {
|
|||
|
||||
#[inline]
|
||||
pub fn frame_to_usec (&self, frame: usize) -> usize {
|
||||
frame * 1000000 / self.rate
|
||||
frame * 1000000 / self.rate()
|
||||
}
|
||||
#[inline]
|
||||
pub fn usec_to_frame (&self, usec: usize) -> usize {
|
||||
frame * self.rate / 1000
|
||||
usec * self.rate() / 1000
|
||||
}
|
||||
#[inline]
|
||||
pub fn usec_per_bar (&self, beats_per_bar: usize) -> usize {
|
||||
|
|
@ -111,7 +122,7 @@ impl Timebase {
|
|||
}
|
||||
#[inline]
|
||||
pub fn usec_per_beat (&self) -> usize {
|
||||
60_000_000_000 / self.tempo
|
||||
60_000_000_000 / self.tempo()
|
||||
}
|
||||
#[inline]
|
||||
pub fn usec_per_step (&self, divisor: usize) -> usize {
|
||||
|
|
@ -119,7 +130,7 @@ impl Timebase {
|
|||
}
|
||||
#[inline]
|
||||
pub fn usec_per_tick (&self) -> usize {
|
||||
self.usec_per_beat() / self.ppq
|
||||
self.usec_per_beat() / self.ppq()
|
||||
}
|
||||
#[inline]
|
||||
pub fn usec_per_note (&self, note: &NoteDuration) -> usize {
|
||||
|
|
@ -132,6 +143,10 @@ impl Timebase {
|
|||
self.usec_per_note(note) * 2 / *n,
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn frame_per_note (&self, note: &NoteDuration) -> usize {
|
||||
self.usec_to_frame(self.usec_per_note(note))
|
||||
}
|
||||
pub fn quantize (&self, step: &NoteDuration, time: usize) -> (usize, usize) {
|
||||
let step = self.usec_per_note(step);
|
||||
let time = time / step;
|
||||
|
|
@ -146,19 +161,3 @@ impl Timebase {
|
|||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl NoteDuration {
|
||||
fn to_usec (&self, bpm: &Tempo) -> Usec {
|
||||
Usec(match self {
|
||||
Self::Nth(time, flies) =>
|
||||
bpm.usec_per_beat().0 * *time as usize / *flies as usize,
|
||||
Self::Dotted(duration) =>
|
||||
duration.to_usec(bpm).0 * 3 / 2,
|
||||
Self::Tuplet(n, duration) =>
|
||||
duration.to_usec(bpm).0 * 2 / *n as usize,
|
||||
})
|
||||
}
|
||||
fn to_frame (&self, bpm: &Tempo, rate: &Hz) -> Frame {
|
||||
self.to_usec(bpm).to_frame(rate)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue