pick, pickmod, inhabit, inhabitmod (#921)

* the args for `pick` are now reversed as standard (old behaviour still supported to avoid breaking change)
* `pick` is also now a pattern method
* `pick` now also accepts a lookup table for pick-by-name as an alternative to pick-by-index from a list
* `inhabit` added with same behaviour as `pick`, except cycles from source patterns are squeezed into events of inhabited patterns
* Also some general doc tidying, sorry for the noise..
* There is also `pickmod` and `inhabitmod`, for wrapping indexes around rather than clamping them
This commit is contained in:
Alex McLean 2024-01-18 17:08:29 +00:00 committed by GitHub
parent a8db707440
commit 98b7859605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 263 additions and 46 deletions

View File

@ -97,7 +97,7 @@ const generic_params = [
*/ */
['postgain'], ['postgain'],
/** /**
* Like {@link gain}, but linear. * Like `gain`, but linear.
* *
* @name amp * @name amp
* @param {number | Pattern} amount gain. * @param {number | Pattern} amount gain.
@ -856,7 +856,7 @@ const generic_params = [
*/ */
['detune', 'det'], ['detune', 'det'],
/** /**
* Set dryness of reverb. See {@link room} and {@link size} for more information about reverb. * Set dryness of reverb. See `room` and `size` for more information about reverb.
* *
* @name dry * @name dry
* @param {number | Pattern} dry 0 = wet, 1 = dry * @param {number | Pattern} dry 0 = wet, 1 = dry
@ -868,7 +868,7 @@ const generic_params = [
['dry'], ['dry'],
// TODO: does not seem to do anything // TODO: does not seem to do anything
/* /*
* Used when using {@link begin}/{@link end} or {@link chop}/{@link striate} and friends, to change the fade out time of the 'grain' envelope. * Used when using `begin`/`end` or `chop`/`striate` and friends, to change the fade out time of the 'grain' envelope.
* *
* @name fadeTime * @name fadeTime
* @param {number | Pattern} time between 0 and 1 * @param {number | Pattern} time between 0 and 1
@ -1191,7 +1191,7 @@ const generic_params = [
*/ */
[['ir', 'i'], 'iresponse'], [['ir', 'i'], 'iresponse'],
/** /**
* Sets the room size of the reverb, see {@link room}. * Sets the room size of the reverb, see `room`.
* When this property is changed, the reverb will be recaculated, so only change this sparsely.. * When this property is changed, the reverb will be recaculated, so only change this sparsely..
* *
* @name roomsize * @name roomsize
@ -1249,7 +1249,7 @@ const generic_params = [
*/ */
['speed'], ['speed'],
/** /**
* Used in conjunction with {@link speed}, accepts values of "r" (rate, default behavior), "c" (cycles), or "s" (seconds). Using `unit "c"` means `speed` will be interpreted in units of cycles, e.g. `speed "1"` means samples will be stretched to fill a cycle. Using `unit "s"` means the playback speed will be adjusted so that the duration is the number of seconds specified by `speed`. * Used in conjunction with `speed`, accepts values of "r" (rate, default behavior), "c" (cycles), or "s" (seconds). Using `unit "c"` means `speed` will be interpreted in units of cycles, e.g. `speed "1"` means samples will be stretched to fill a cycle. Using `unit "s"` means the playback speed will be adjusted so that the duration is the number of seconds specified by `speed`.
* *
* @name unit * @name unit
* @param {number | string | Pattern} unit see description above * @param {number | string | Pattern} unit see description above

View File

@ -26,7 +26,7 @@ export class Pattern {
/** /**
* Create a pattern. As an end user, you will most likely not create a Pattern directly. * Create a pattern. As an end user, you will most likely not create a Pattern directly.
* *
* @param {function} query - The function that maps a {@link State} to an array of {@link Hap}. * @param {function} query - The function that maps a `State` to an array of `Hap`.
* @noAutocomplete * @noAutocomplete
*/ */
constructor(query) { constructor(query) {
@ -39,7 +39,7 @@ export class Pattern {
/** /**
* Returns a new pattern, with the function applied to the value of * Returns a new pattern, with the function applied to the value of
* each hap. It has the alias {@link Pattern#fmap}. * each hap. It has the alias `fmap`.
* @synonyms fmap * @synonyms fmap
* @param {Function} func to to apply to the value * @param {Function} func to to apply to the value
* @returns Pattern * @returns Pattern
@ -51,7 +51,7 @@ export class Pattern {
} }
/** /**
* see {@link Pattern#withValue} * see `withValue`
* @noAutocomplete * @noAutocomplete
*/ */
fmap(func) { fmap(func) {
@ -115,7 +115,7 @@ export class Pattern {
} }
/** /**
* As with {@link Pattern#appBoth}, but the `whole` timespan is not the intersection, * As with `appBoth`, but the `whole` timespan is not the intersection,
* but the timespan from the function of patterns that this method is called * but the timespan from the function of patterns that this method is called
* on. In practice, this means that the pattern structure, including onsets, * on. In practice, this means that the pattern structure, including onsets,
* are preserved from the pattern of functions (often referred to as the left * are preserved from the pattern of functions (often referred to as the left
@ -148,7 +148,7 @@ export class Pattern {
} }
/** /**
* As with {@link Pattern#appLeft}, but `whole` timespans are instead taken from the * As with `appLeft`, but `whole` timespans are instead taken from the
* pattern of values, i.e. structure is preserved from the right hand/outer * pattern of values, i.e. structure is preserved from the right hand/outer
* pattern. * pattern.
* @param {Pattern} pat_val * @param {Pattern} pat_val
@ -387,7 +387,7 @@ export class Pattern {
} }
/** /**
* As with {@link Pattern#withQuerySpan}, but the function is applied to both the * As with `withQuerySpan`, but the function is applied to both the
* begin and end time of the query timespan. * begin and end time of the query timespan.
* @param {Function} func the function to apply * @param {Function} func the function to apply
* @returns Pattern * @returns Pattern
@ -398,7 +398,7 @@ export class Pattern {
} }
/** /**
* Similar to {@link Pattern#withQuerySpan}, but the function is applied to the timespans * Similar to `withQuerySpan`, but the function is applied to the timespans
* of all haps returned by pattern queries (both `part` timespans, and where * of all haps returned by pattern queries (both `part` timespans, and where
* present, `whole` timespans). * present, `whole` timespans).
* @param {Function} func * @param {Function} func
@ -410,7 +410,7 @@ export class Pattern {
} }
/** /**
* As with {@link Pattern#withHapSpan}, but the function is applied to both the * As with `withHapSpan`, but the function is applied to both the
* begin and end time of the hap timespans. * begin and end time of the hap timespans.
* @param {Function} func the function to apply * @param {Function} func the function to apply
* @returns Pattern * @returns Pattern
@ -431,7 +431,7 @@ export class Pattern {
} }
/** /**
* As with {@link Pattern#withHaps}, but applies the function to every hap, rather than every list of haps. * As with `withHaps`, but applies the function to every hap, rather than every list of haps.
* @param {Function} func * @param {Function} func
* @returns Pattern * @returns Pattern
* @noAutocomplete * @noAutocomplete
@ -499,7 +499,7 @@ export class Pattern {
} }
/** /**
* As with {@link Pattern#filterHaps}, but the function is applied to values * As with `filterHaps`, but the function is applied to values
* inside haps. * inside haps.
* @param {Function} value_test * @param {Function} value_test
* @returns Pattern * @returns Pattern
@ -621,7 +621,7 @@ export class Pattern {
} }
/** /**
* More human-readable version of the {@link Pattern#firstCycleValues} accessor. * More human-readable version of the `firstCycleValues` accessor.
* @noAutocomplete * @noAutocomplete
*/ */
get showFirstCycle() { get showFirstCycle() {
@ -691,7 +691,7 @@ export class Pattern {
// Methods without corresponding toplevel functions // Methods without corresponding toplevel functions
/** /**
* Layers the result of the given function(s). Like {@link Pattern.superimpose}, but without the original pattern: * Layers the result of the given function(s). Like `superimpose`, but without the original pattern:
* @name layer * @name layer
* @memberof Pattern * @memberof Pattern
* @synonyms apply * @synonyms apply
@ -1189,7 +1189,7 @@ export function stack(...pats) {
/** Concatenation: combines a list of patterns, switching between them successively, one per cycle: /** Concatenation: combines a list of patterns, switching between them successively, one per cycle:
* *
* synonyms: {@link cat} * synonyms: `cat`
* *
* @return {Pattern} * @return {Pattern}
* @example * @example
@ -1244,7 +1244,7 @@ export function cat(...pats) {
return slowcat(...pats); return slowcat(...pats);
} }
/** Like {@link Pattern.seq}, but each step has a length, relative to the whole. /** Like `seq`, but each step has a length, relative to the whole.
* @return {Pattern} * @return {Pattern}
* @example * @example
* timeCat([3,"e3"],[1, "g3"]).note() // "e3@3 g3".note() * timeCat([3,"e3"],[1, "g3"]).note() // "e3@3 g3".note()
@ -1279,7 +1279,7 @@ export function fastcat(...pats) {
return slowcat(...pats)._fast(pats.length); return slowcat(...pats)._fast(pats.length);
} }
/** See {@link fastcat} */ /** See `fastcat` */
export function sequence(...pats) { export function sequence(...pats) {
return fastcat(...pats); return fastcat(...pats);
} }
@ -1636,7 +1636,7 @@ export const { fastGap, fastgap } = register(['fastGap', 'fastgap'], function (f
}); });
/** /**
* Similar to compress, but doesn't leave gaps, and the 'focus' can be bigger than a cycle * Similar to `compress`, but doesn't leave gaps, and the 'focus' can be bigger than a cycle
* @example * @example
* s("bd hh sd hh").focus(1/4, 3/4) * s("bd hh sd hh").focus(1/4, 3/4)
*/ */
@ -1753,7 +1753,7 @@ export const lastOf = register('lastOf', function (n, func, pat) {
*/ */
/** /**
* An alias for {@link firstOf} * An alias for `firstOf`
* @name every * @name every
* @memberof Pattern * @memberof Pattern
* @param {number} n how many cycles * @param {number} n how many cycles
@ -2365,7 +2365,7 @@ export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (facto
// It is still here to work in cases where repl.mjs is not used // It is still here to work in cases where repl.mjs is not used
/** /**
* Makes the sample fit its event duration. Good for rhythmical loops like drum breaks. * Makes the sample fit its event duration. Good for rhythmical loops like drum breaks.
* Similar to loopAt. * Similar to `loopAt`.
* @name fit * @name fit
* @example * @example
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' }) * samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })

View File

@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { Hap } from './hap.mjs'; import { Hap } from './hap.mjs';
import { Pattern, fastcat, reify, silence, stack, register } from './pattern.mjs'; import { Pattern, fastcat, reify, silence, stack, register } from './pattern.mjs';
import Fraction from './fraction.mjs'; import Fraction from './fraction.mjs';
import { id, _mod, clamp } from './util.mjs'; import { id, _mod, clamp, objectMap } from './util.mjs';
export function steady(value) { export function steady(value) {
// A continuous value // A continuous value
@ -156,31 +156,96 @@ export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i));
*/ */
export const irand = (ipat) => reify(ipat).fmap(_irand).innerJoin(); export const irand = (ipat) => reify(ipat).fmap(_irand).innerJoin();
/** const _pick = function (lookup, pat, modulo = true) {
* pick from the list of values (or patterns of values) via the index using the given const array = Array.isArray(lookup);
* pattern of integers const len = Object.keys(lookup).length;
lookup = objectMap(lookup, reify);
if (len === 0) {
return silence;
}
return pat.fmap((i) => {
let key = i;
if (array) {
key = modulo ? Math.round(key) % len : clamp(Math.round(key), 0, lookup.length - 1);
}
return lookup[key];
});
};
/** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
* Similar to `inhabit`, but maintains the structure of the original patterns.
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* note(pick("<0 1 [2!2] 3>", ["g a", "e f", "f g f g" , "g a c d"])) * note("<0 1 2!2 3>".pick(["g a", "e f", "f g f g" , "g c d"]))
* @example
* sound("<0 1 [2,0]>".pick(["bd sd", "cp cp", "hh hh"]))
* @example
* sound("<0!2 [0,1] 1>".pick(["bd(3,8)", "sd sd"]))
* @example
* s("<a!2 [a,b] b>".pick({a: "bd(3,8)", b: "sd sd"}))
*/ */
export const pick = (pat, xs) => { export const pick = function (lookup, pat) {
xs = xs.map(reify); // backward compatibility - the args used to be flipped
if (xs.length == 0) { if (Array.isArray(pat)) {
return silence; [pat, lookup] = [lookup, pat];
} }
return pat return __pick(lookup, pat);
.fmap((i) => {
const key = clamp(Math.round(i), 0, xs.length - 1);
return xs[key];
})
.innerJoin();
}; };
const __pick = register('pick', function (lookup, pat) {
return _pick(lookup, pat, false).innerJoin();
});
/** * The same as `pick`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* For example, if you pick the fifth pattern of a list of three, you'll get the
* second one.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const pickmod = register('pickmod', function (lookup, pat) {
return _pick(lookup, pat, true).innerJoin();
});
/** /**
* pick from the list of values (or patterns of values) via the index using the given /** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* "<a b [a,b]>".inhabit({a: s("bd(3,8)"),
b: s("cp sd")
})
* @example
* s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4)
*/
export const inhabit = register('inhabit', function (lookup, pat) {
return _pick(lookup, pat, true).squeezeJoin();
});
/** * The same as `inhabit`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value.
* For example, if you pick the fifth pattern of a list of three, you'll get the
* second one.
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
*/
export const inhabitmod = register('inhabit', function (lookup, pat) {
return _pick(lookup, pat, false).squeezeJoin();
});
/**
* Pick from the list of values (or patterns of values) via the index using the given
* pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event * pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
@ -356,7 +421,7 @@ export const degradeBy = register('degradeBy', function (x, pat) {
export const degrade = register('degrade', (pat) => pat._degradeBy(0.5)); export const degrade = register('degrade', (pat) => pat._degradeBy(0.5));
/** /**
* Inverse of {@link Pattern#degradeBy}: Randomly removes events from the pattern by a given amount. * Inverse of `degradeBy`: Randomly removes events from the pattern by a given amount.
* 0 = 100% chance of removal * 0 = 100% chance of removal
* 1 = 0% chance of removal * 1 = 0% chance of removal
* Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example). * Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example).
@ -380,7 +445,7 @@ export const undegrade = register('undegrade', (pat) => pat._undegradeBy(0.5));
/** /**
* *
* Randomly applies the given function by the given probability. * Randomly applies the given function by the given probability.
* Similar to {@link Pattern#someCyclesBy} * Similar to `someCyclesBy`
* *
* @name sometimesBy * @name sometimesBy
* @memberof Pattern * @memberof Pattern
@ -415,7 +480,7 @@ export const sometimes = register('sometimes', function (func, pat) {
/** /**
* *
* Randomly applies the given function by the given probability on a cycle by cycle basis. * Randomly applies the given function by the given probability on a cycle by cycle basis.
* Similar to {@link Pattern#sometimesBy} * Similar to `sometimesBy`
* *
* @name someCyclesBy * @name someCyclesBy
* @memberof Pattern * @memberof Pattern

View File

@ -46,6 +46,7 @@ import {
rev, rev,
time, time,
run, run,
pick,
} from '../index.mjs'; } from '../index.mjs';
import { steady } from '../signal.mjs'; import { steady } from '../signal.mjs';
@ -1057,4 +1058,63 @@ describe('Pattern', () => {
expect(slowcat(0, 1).repeatCycles(2).fast(6).firstCycleValues).toStrictEqual([0, 0, 1, 1, 0, 0]); expect(slowcat(0, 1).repeatCycles(2).fast(6).firstCycleValues).toStrictEqual([0, 0, 1, 1, 0, 0]);
}); });
}); });
describe('inhabit', () => {
it('Can pattern named patterns', () => {
expect(
sameFirst(
sequence('a', 'b', stack('a', 'b')).inhabit({ a: sequence(1, 2), b: sequence(10, 20, 30) }),
sequence([1, 2], [10, 20, 30], stack([1, 2], [10, 20, 30])),
),
);
});
it('Can pattern indexed patterns', () => {
expect(
sameFirst(
sequence('0', '1', stack('0', '1')).inhabit([sequence(1, 2), sequence(10, 20, 30)]),
sequence([1, 2], [10, 20, 30], stack([1, 2], [10, 20, 30])),
),
);
});
});
describe('pick', () => {
it('Can pattern named patterns', () => {
expect(
sameFirst(
sequence('a', 'b', 'a', stack('a', 'b')).pick({ a: sequence(1, 2, 3, 4), b: sequence(10, 20, 30, 40) }),
sequence(1, 20, 3, stack(4, 40)),
),
);
});
it('Can pattern indexed patterns', () => {
expect(
sameFirst(
sequence(0, 1, 0, stack(0, 1)).pick([sequence(1, 2, 3, 4), sequence(10, 20, 30, 40)]),
sequence(1, 20, 3, stack(4, 40)),
),
);
});
it('Clamps indexes', () => {
expect(
sameFirst(sequence(0, 1, 2, 3).pick([sequence(1, 2, 3, 4), sequence(10, 20, 30, 40)]), sequence(1, 20, 30, 40)),
);
});
it('Is backwards compatible', () => {
expect(
sameFirst(
pick([sequence('a', 'b'), sequence('c', 'd')], sequence(0, 1)),
pick(sequence(0, 1), [sequence('a', 'b'), sequence('c', 'd')]),
),
);
});
});
describe('pickmod', () => {
it('Wraps indexes', () => {
expect(
sameFirst(
sequence(0, 1, 2, 3).pickmod([sequence(1, 2, 3, 4), sequence(10, 20, 30, 40)]),
sequence(1, 20, 3, 40),
),
);
});
});
}); });

View File

@ -316,3 +316,10 @@ export function hash2code(hash) {
return base64ToUnicode(decodeURIComponent(hash)); return base64ToUnicode(decodeURIComponent(hash));
//return atob(decodeURIComponent(codeParam || '')); //return atob(decodeURIComponent(codeParam || ''));
} }
export function objectMap(obj, fn) {
if (Array.isArray(obj)) {
return obj.map(fn);
}
return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)]));
}

View File

@ -2408,6 +2408,40 @@ exports[`runs examples > example "hush" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "inhabit" example index 0 1`] = `
[
"[ 0/1 → 1/8 | s:bd ]",
"[ 3/8 → 1/2 | s:bd ]",
"[ 3/4 → 7/8 | s:bd ]",
"[ 1/1 → 3/2 | s:cp ]",
"[ 3/2 → 2/1 | s:sd ]",
"[ 2/1 → 17/8 | s:bd ]",
"[ 2/1 → 5/2 | s:cp ]",
"[ 19/8 → 5/2 | s:bd ]",
"[ 5/2 → 3/1 | s:sd ]",
"[ 11/4 → 23/8 | s:bd ]",
"[ 3/1 → 25/8 | s:bd ]",
"[ 27/8 → 7/2 | s:bd ]",
"[ 15/4 → 31/8 | s:bd ]",
]
`;
exports[`runs examples > example "inhabit" example index 1 1`] = `
[
"[ 0/1 → 1/4 | s:bd ]",
"[ 3/4 → 1/1 | s:bd ]",
"[ 3/2 → 7/4 | s:bd ]",
"[ 2/1 → 33/16 | s:bd ]",
"[ 35/16 → 9/4 | s:bd ]",
"[ 19/8 → 39/16 | s:bd ]",
"[ 5/2 → 11/4 | s:sd ]",
"[ 11/4 → 3/1 | s:sd ]",
"[ 3/1 → 25/8 | s:bd ]",
"[ 27/8 → 7/2 | s:bd ]",
"[ 15/4 → 31/8 | s:bd ]",
]
`;
exports[`runs examples > example "inside" example index 0 1`] = ` exports[`runs examples > example "inside" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | note:D3 ]", "[ 0/1 → 1/8 | note:D3 ]",
@ -3601,10 +3635,61 @@ exports[`runs examples > example "pick" example index 0 1`] = `
"[ 9/4 → 5/2 | note:g ]", "[ 9/4 → 5/2 | note:g ]",
"[ 5/2 → 11/4 | note:f ]", "[ 5/2 → 11/4 | note:f ]",
"[ 11/4 → 3/1 | note:g ]", "[ 11/4 → 3/1 | note:g ]",
"[ 3/1 → 13/4 | note:g ]", "[ 3/1 → 13/4 | note:f ]",
"[ 13/4 → 7/2 | note:a ]", "[ 13/4 → 7/2 | note:g ]",
"[ 7/2 → 15/4 | note:c ]", "[ 7/2 → 15/4 | note:f ]",
"[ 15/4 → 4/1 | note:d ]", "[ 15/4 → 4/1 | note:g ]",
]
`;
exports[`runs examples > example "pick" example index 1 1`] = `
[
"[ 0/1 → 1/2 | s:bd ]",
"[ 1/2 → 1/1 | s:sd ]",
"[ 1/1 → 3/2 | s:cp ]",
"[ 3/2 → 2/1 | s:cp ]",
"[ 2/1 → 5/2 | s:hh ]",
"[ 2/1 → 5/2 | s:bd ]",
"[ 5/2 → 3/1 | s:hh ]",
"[ 5/2 → 3/1 | s:sd ]",
"[ 3/1 → 7/2 | s:bd ]",
"[ 7/2 → 4/1 | s:sd ]",
]
`;
exports[`runs examples > example "pick" example index 2 1`] = `
[
"[ 0/1 → 1/8 | s:bd ]",
"[ 3/8 → 1/2 | s:bd ]",
"[ 3/4 → 7/8 | s:bd ]",
"[ 1/1 → 9/8 | s:bd ]",
"[ 11/8 → 3/2 | s:bd ]",
"[ 7/4 → 15/8 | s:bd ]",
"[ 2/1 → 17/8 | s:bd ]",
"[ 2/1 → 5/2 | s:sd ]",
"[ 19/8 → 5/2 | s:bd ]",
"[ 5/2 → 3/1 | s:sd ]",
"[ 11/4 → 23/8 | s:bd ]",
"[ 3/1 → 7/2 | s:sd ]",
"[ 7/2 → 4/1 | s:sd ]",
]
`;
exports[`runs examples > example "pick" example index 3 1`] = `
[
"[ 0/1 → 1/8 | s:bd ]",
"[ 3/8 → 1/2 | s:bd ]",
"[ 3/4 → 7/8 | s:bd ]",
"[ 1/1 → 9/8 | s:bd ]",
"[ 11/8 → 3/2 | s:bd ]",
"[ 7/4 → 15/8 | s:bd ]",
"[ 2/1 → 17/8 | s:bd ]",
"[ 2/1 → 5/2 | s:sd ]",
"[ 19/8 → 5/2 | s:bd ]",
"[ 5/2 → 3/1 | s:sd ]",
"[ 11/4 → 23/8 | s:bd ]",
"[ 3/1 → 7/2 | s:sd ]",
"[ 7/2 → 4/1 | s:sd ]",
] ]
`; `;